Catalyst-Plugin-OpenIDConnect

 view release on metacpan or  search on metacpan

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

sub logout : Local {
    my ( $self, $c ) = @_;

    my $config = $c->openidconnect->config;

    $c->log->debug('Logout endpoint accessed') if $config->{debug};

    my $redirect_uri   = $c->request->params->{post_logout_redirect_uri};
    my $id_token_hint  = $c->request->params->{id_token_hint};
    my $state          = $c->request->params->{state};

    # Decode the hint early — before the session is destroyed — so that we
    # have the subject identifier available for refresh token revocation (MED-1).
    my $hint_claims;
    if ($id_token_hint) {
        $hint_claims = $c->openidconnect->jwt->decode_id_token_hint($id_token_hint);
    }

    # Determine the subject identifier for refresh token revocation.  Prefer
    # the hint (authoritative); fall back to the live session so that
    # logout-without-hint still revokes tokens when the session is present.
    my $logout_sub = do {
        if ( $hint_claims && $hint_claims->{sub} ) {
            $hint_claims->{sub};
        }
        else {
            my $sess_user = eval { $c->session->{user} };
            $sess_user ? ( $sess_user->{sub} || $sess_user->{id} ) : undef;
        }
    };

    # Clear user session
    if ( $c->user ) {
        $c->log->info('Logging out user: ' . $c->user->id);
        $c->user->logout();
    }

    # Destroy session
    if ( $c->sessionid ) {
        $c->log->debug('Destroying session: ' . $c->sessionid) if $config->{debug};
        $c->delete_session('User session destroyed');
    }

    # Revoke all outstanding refresh tokens for this user (MED-1).
    if ($logout_sub) {
        $c->openidconnect->store->revoke_refresh_tokens_for_user($logout_sub);
        $c->log->debug("Refresh tokens revoked for user: $logout_sub")
            if $config->{debug};
    }

    if ($redirect_uri) {
        # id_token_hint is required when a redirect is requested so that we
        # can identify the client and verify the URI is registered for it.
        # Without this check an attacker could redirect to any arbitrary URL.
        unless ($id_token_hint) {
            $c->log->warn('post_logout_redirect_uri provided without id_token_hint');
            return $self->_json_error( $c, 'invalid_request',
                'id_token_hint is required when post_logout_redirect_uri is provided' );
        }

        # $hint_claims was decoded above; reject if invalid.
        unless ($hint_claims) {
            $c->log->warn('Invalid id_token_hint provided at logout');
            return $self->_json_error( $c, 'invalid_request', 'Invalid id_token_hint' );
        }

        # aud may be a string or an array per RFC 7519 §4.1.3.
        my $aud       = $hint_claims->{aud};
        my $client_id = ref $aud eq 'ARRAY' ? $aud->[0] : $aud;

        unless ($client_id) {
            $c->log->warn('id_token_hint is missing the aud claim');
            return $self->_json_error( $c, 'invalid_request',
                'id_token_hint does not contain an aud claim' );
        }

        # Look up the client and validate the redirect URI against its
        # registered post_logout_redirect_uris list.
        my $client = $c->openidconnect->get_client($client_id);
        unless ($client) {
            $c->log->warn("Unknown client in id_token_hint aud claim: $client_id");
            return $self->_json_error( $c, 'invalid_request',
                'Unknown client in id_token_hint' );
        }

        my @allowed = _normalize_uri_list( $client->{post_logout_redirect_uris} );
        unless ( grep { $_ eq $redirect_uri } @allowed ) {
            $c->log->warn(
                "Unregistered post_logout_redirect_uri for client $client_id: $redirect_uri"
            );
            return $self->_json_error( $c, 'invalid_request',
                'post_logout_redirect_uri is not registered for this client' );
        }

        # Build the final redirect URI, appending state if supplied.
        my $final_uri = URI->new($redirect_uri);
        $final_uri->query_form( $final_uri->query_form, state => $state )
            if defined $state && $state ne '';

        $c->log->debug( 'Redirecting to post-logout URI: ' . $final_uri->as_string )
            if $config->{debug};
        return $c->response->redirect( $final_uri->as_string );
    }

    # Return success JSON response
    $c->log->info('Logout completed successfully');
    $self->_json_response( $c, {
        message => 'Logged out successfully',
    });
}

# Verify a PKCE code_verifier against a stored code_challenge.
# Only S256 (SHA-256) is supported; 'plain' is intentionally rejected.
# Returns a true value on success, false on failure.
sub _verify_pkce {
    my ( $code_verifier, $code_challenge ) = @_;
    return 0 unless defined $code_verifier && defined $code_challenge;
    # Verifier must contain only unreserved URI chars and be 43-128 chars (RFC 7636 §4.1)
    return 0 unless $code_verifier =~ /\A[A-Za-z0-9\-._~]{43,128}\z/;
    # S256: BASE64URL( SHA256( ASCII( code_verifier ) ) )
    my $computed = encode_base64url( sha256($code_verifier) );



( run in 0.528 second using v1.01-cache-2.11-cpan-6aa56a78535 )