Catalyst-Plugin-OpenIDConnect

 view release on metacpan or  search on metacpan

lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm  view on Meta::CPAN


    unless ( $code && $redirect_uri ) {
        $c->log->warn('Missing code or redirect_uri in token request');
        return $self->_json_error( $c, 'invalid_request', 'code and redirect_uri are required' );
    }

    $c->log->debug("Token request - code: $code, client_id: $client_id") if $config->{debug};

    # Atomically consume (fetch + delete) the authorization code.
    # Using consume_authorization_code rather than a separate get + delete
    # eliminates the TOCTOU race that would otherwise allow two concurrent
    # requests carrying the same code to both succeed (HIGH-4).
    # Per RFC 6749 §4.1.2 any code that fails subsequent validation must
    # also be treated as used, which this pattern naturally enforces.
    my $code_data = $c->openidconnect->store->consume_authorization_code($code);
    unless ($code_data) {
        $c->log->warn("Authorization code not found, expired, or already used: $code");
        return $self->_json_error( $c, 'invalid_grant', 'Authorization code not found or expired' );
    }
    $c->log->debug("Authorization code consumed: $code") if $config->{debug};

    # Remove session copy of this code so stale claims/scope/nonce do not
    # accumulate in the session store beyond the code's 10-minute lifetime (MED-5).
    delete $c->session->{oidc_code}->{$code};

    # Use client_id from authorization code if not provided in request (public client flow)
    $client_id ||= $code_data->{client_id};

    # Enforce that the client presenting the token request is the same client
    # the authorization code was issued to (RFC 6749 §4.1.3, NEW-HIGH-2).
    # Without this check a confidential client that obtains another client's
    # code could redeem it by authenticating with its own valid secret.
    if ( $client_id ne $code_data->{client_id} ) {
        $c->log->warn(
            "client_id mismatch at token endpoint: "
            . "request=$client_id stored=$code_data->{client_id}"
        );
        return $self->_json_error( $c, 'invalid_grant',
            'client_id does not match the authorization code' );
    }

    # Verify redirect URI matches
    unless ( $code_data->{redirect_uri} eq $redirect_uri ) {
        $c->log->error("Redirect URI mismatch for code: $code (expected: " . $code_data->{redirect_uri} . ", got: $redirect_uri)");
        return $self->_json_error( $c, 'invalid_grant', 'Redirect URI mismatch' );
    }

    # PKCE verification (RFC 7636)
    if ( $code_data->{code_challenge} ) {
        unless ( defined $code_verifier ) {
            $c->log->warn("PKCE code_verifier missing for client: $client_id");
            return $self->_json_error( $c, 'invalid_grant', 'code_verifier is required' );
        }
        unless ( _verify_pkce( $code_verifier, $code_data->{code_challenge} ) ) {
            $c->log->warn("PKCE verification failed for client: $client_id");
            return $self->_json_error( $c, 'invalid_grant', 'code_verifier is invalid' );
        }
        $c->log->debug('PKCE verification passed') if $config->{debug};
    }

    # If client_secret is provided, verify client credentials (confidential client)
    if ($client_secret) {
        $c->log->debug("Verifying client credentials for: $client_id") if $config->{debug};
        my $client = $c->openidconnect->get_client($client_id);
        unless ( $client && slow_eq( $client->{client_secret}, $client_secret ) ) {
            $c->log->warn("Client authentication failed for: $client_id");
            return $self->_json_error( $c, 'invalid_client', 'Client authentication failed' );
        }
    } else {
        # For public clients (no secret provided), at least verify client exists
        my $client = $c->openidconnect->get_client($client_id);
        unless ($client) {
            $c->log->warn("Unknown client: $client_id");
            return $self->_json_error( $c, 'invalid_client', 'Unknown client' );
        }
    }

    # User claims were extracted and stored at authorization time, so
    # $code_data->{user} is already the mapped claims hashref.
    my $user_claims = $code_data->{user};

    # Create tokens
    my $now = time();
    my %id_token_payload = (
        %$user_claims,
        aud => $client_id,
        exp => $now + 3600,  # 1 hour
    );

    $id_token_payload{nonce} = $code_data->{nonce} if $code_data->{nonce};

    my $id_token = $c->openidconnect->jwt->create_id_token(%id_token_payload);
    $c->log->debug('ID token created') if $config->{debug};

    my %access_token_payload = (
        sub => $user_claims->{sub},
        aud => $client_id,
        scp => $code_data->{scope},
        typ => 'at+JWT',  # RFC 9068 — distinguishes access tokens from ID/refresh tokens (NEW-MED-1)
        exp => $now + 3600,
    );

    my $access_token = $c->openidconnect->jwt->create_access_token(%access_token_payload);
    $c->log->debug('Access token created') if $config->{debug};

    # Issue a refresh token with a unique JTI and register the JTI in the
    # store so the token endpoint can enforce single-use semantics (MED-1).
    my $rt_jti = $_uuid->create_str();
    my $rt_ttl = 30 * 24 * 3600;  # 30 days
    my %refresh_token_payload = (
        sub => $user_claims->{sub},
        aud => $client_id,
        jti => $rt_jti,
        exp => $now + $rt_ttl,
    );

    my $refresh_token = $c->openidconnect->jwt->create_refresh_token(%refresh_token_payload);
    $c->openidconnect->store->store_refresh_token(
        $rt_jti, $user_claims->{sub}, $client_id, $rt_ttl,
    );
    $c->log->debug('Refresh token created and JTI registered') if $config->{debug};

    $c->log->info("Tokens issued for client: $client_id, user: " . $user_claims->{sub});



( run in 0.888 second using v1.01-cache-2.11-cpan-cdf2f3d4e48 )