Catalyst-Plugin-OpenIDConnect
view release on metacpan or search on metacpan
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
package Catalyst::Plugin::OpenIDConnect::Controller::Root;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
use JSON::MaybeXS qw(encode_json decode_json);
use MIME::Base64 qw(encode_base64 decode_base64 encode_base64url);
use Digest::SHA qw(sha256);
use Crypt::PK::RSA;
use Crypt::Misc qw(slow_eq);
use URI;
use DateTime;
use Try::Tiny;
use Data::UUID;
# Set the namespace for OpenIDConnect routes
__PACKAGE__->config(namespace => 'openidconnect');
# Module-level UUID generator for refresh token JTI claims (MED-1).
my $_uuid = Data::UUID->new();
=head1 NAME
Catalyst::Plugin::OpenIDConnect::Controller::Root - OIDC Protocol Endpoints
=head1 SYNOPSIS
Handles OpenID Connect protocol endpoints:
/.well-known/openid-configuration - Discovery endpoint
/openidconnect/authorize - Authorization endpoint
/openidconnect/token - Token endpoint
/openidconnect/userinfo - UserInfo endpoint
/openidconnect/logout - Logout endpoint
/openidconnect/jwks - JWKS endpoint for key discovery
=cut
=head1 DESCRIPTION
This controller implements the core OpenID Connect protocol endpoints. To use it in your application, create a controller that extends this one:
package MyApp::Controller::Auth;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Plugin::OpenIDConnect::Controller::Root'; }
__PACKAGE__->meta->make_immutable;
=head1 METHODS
=head2 begin
Called automatically before every action in this controller. Sets HTTP
security headers that must be present on all OIDC endpoint responses.
=cut
sub begin : Private {
my ( $self, $c ) = @_;
# RFC 6749 §5.1 requires Cache-Control: no-store on token responses;
# applied globally so new endpoints can't accidentally omit it.
# Pragma: no-cache is the HTTP/1.0 equivalent.
$c->response->header( 'Cache-Control' => 'no-store' );
$c->response->header( 'Pragma' => 'no-cache' );
# Prevent MIME sniffing.
$c->response->header( 'X-Content-Type-Options' => 'nosniff' );
# Clickjacking protection on the authorize endpoint HTML page.
# Both headers are set for broadest browser compatibility (MED-6).
$c->response->header( 'X-Frame-Options' => 'DENY' );
$c->response->header( 'Content-Security-Policy' => "frame-ancestors 'none'" );
}
=head2 discovery
GET /.well-known/openid-configuration
Returns the OpenID Connect provider configuration.
=cut
sub discovery : Path('/.well-known/openid-configuration') {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('OpenID Connect discovery endpoint accessed') if $config->{debug};
$self->_json_response( $c, $c->openidconnect->get_discovery() );
}
=head2 authorize
GET /openidconnect/authorize
OpenID Connect authorization endpoint.
Query parameters:
- response_type (REQUIRED): "code"
- client_id (REQUIRED): The client ID
- redirect_uri (REQUIRED): Where to redirect after authorization
- scope (RECOMMENDED): Space-separated scopes (default: "openid")
- state (RECOMMENDED): CSRF protection state parameter
- nonce (OPTIONAL): String to bind to the ID token
=cut
sub authorize : Local {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('Authorization endpoint accessed') if $config->{debug};
my $response_type = $c->request->params->{response_type};
my $client_id = $c->request->params->{client_id};
my $redirect_uri = $c->request->params->{redirect_uri};
my $scope = $c->request->params->{scope};
my $state = $c->request->params->{state};
my $nonce = $c->request->params->{nonce};
my $code_challenge = $c->request->params->{code_challenge};
my $code_challenge_method = $c->request->params->{code_challenge_method};
$c->log->debug("Authorization request - client_id: $client_id, response_type: $response_type, redirect_uri: $redirect_uri") if $config->{debug};
my $stored_auth_request = $c->session->{oidc_auth_request} || {};
$response_type //= $stored_auth_request->{response_type};
$client_id //= $stored_auth_request->{client_id};
$redirect_uri //= $stored_auth_request->{redirect_uri};
$scope ||= $stored_auth_request->{scope} || 'openid';
$state //= $stored_auth_request->{state};
$nonce //= $stored_auth_request->{nonce};
$code_challenge //= $stored_auth_request->{code_challenge};
$code_challenge_method //= $stored_auth_request->{code_challenge_method};
# --- Phase 1: validate client_id and redirect_uri BEFORE using either in a
# redirect-based error response (NEW-HIGH-1 / RFC 6749 §4.1.2.1).
# Any error raised here returns a direct HTTP 400; we must never redirect
# to a URI that has not yet been confirmed as registered for the client.
unless ($client_id) {
$c->log->warn('Missing client_id parameter');
return $self->_json_error( $c, 'invalid_request', 'client_id is required' );
}
unless ($redirect_uri) {
$c->log->warn('Missing redirect_uri parameter');
return $self->_json_error( $c, 'invalid_request', 'redirect_uri is required' );
}
# Resolve client â must succeed before redirect_uri can be validated.
my $client = $c->openidconnect->get_client($client_id);
unless ($client) {
$c->log->error("Unknown client: $client_id");
return $self->_json_error( $c, 'invalid_client', 'Unknown client' );
}
# Validate redirect_uri against the registered list.
# Only after this check is it safe to use $redirect_uri in _error_response.
my @allowed_uris = _normalize_uri_list( $client->{redirect_uris} );
unless ( grep { $_ eq $redirect_uri } @allowed_uris ) {
$c->log->error("Redirect URI mismatch for client $client_id: $redirect_uri");
return $self->_json_error( $c, 'invalid_request',
'Redirect URI not registered' );
}
# --- Phase 2: remaining parameter validation â redirect_uri is now
# confirmed registered so _error_response redirects are safe from here on.
unless ( $response_type && $response_type eq 'code' ) {
$c->log->warn("Invalid response_type: $response_type");
return $self->_error_response(
$c, $redirect_uri, 'invalid_request',
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
$c->log->debug('User not authenticated, redirecting to login') if $config->{debug};
$c->session->{oidc_auth_request} = {
response_type => $response_type,
client_id => $client_id,
redirect_uri => $redirect_uri,
scope => $scope,
state => $state,
nonce => $nonce,
code_challenge => $code_challenge,
code_challenge_method => $code_challenge_method,
};
return $c->response->redirect( $c->uri_for('/login', { back => '/openidconnect/authorize' }) );
}
$c->log->info("Authorization granted for user: " . $c->user->id . " to client: $client_id");
# PKCE validation (RFC 7636, OAuth 2.1).
# Public clients (no client_secret) MUST supply a code_challenge.
# Confidential clients MAY supply one; if they do, it is validated.
# Only S256 is accepted â 'plain' provides no meaningful security.
my $is_public_client = !( $client->{client_secret} && length $client->{client_secret} );
if ( $is_public_client && !$code_challenge ) {
$c->log->warn("PKCE code_challenge required for public client: $client_id");
return $self->_error_response(
$c, $redirect_uri, 'invalid_request',
'code_challenge is required for public clients', $state
);
}
if ( $code_challenge ) {
my $method = $code_challenge_method // 'plain';
unless ( $method eq 'S256' ) {
$c->log->warn("Unsupported code_challenge_method '$method' for client: $client_id");
return $self->_error_response(
$c, $redirect_uri, 'invalid_request',
'code_challenge_method must be S256', $state
);
}
# Validate the challenge value format (NEW-LOW-1 / RFC 7636 §4.2).
# An S256 challenge is BASE64URL(SHA256(verifier)) â exactly 43 chars
# from the BASE64URL alphabet with no padding. Reject malformed values
# before they reach the store or any downstream comparison.
unless ( $code_challenge =~ /\A[A-Za-z0-9\-_]{43}\z/ ) {
$c->log->warn("Malformed code_challenge for client $client_id");
return $self->_error_response(
$c, $redirect_uri, 'invalid_request',
'code_challenge must be a 43-character BASE64URL-encoded string',
$state
);
}
}
# Extract user claims now, while the live user object is available.
# Storing the plain claims hashref (rather than the user object itself)
# means the store never needs to serialise application-specific objects
# such as DBIx::Class rows or LDAP entries â it always receives and
# returns plain data.
my $user_claims = $c->openidconnect->get_user_claims( $c->user );
# Create authorization code, passing any PKCE challenge so it is stored
# alongside the code and can be verified at the token endpoint.
my $pkce = $code_challenge
? { code_challenge => $code_challenge, code_challenge_method => 'S256' }
: undef;
my $code = $c->openidconnect->store->create_authorization_code(
$client_id, $user_claims, $scope, $redirect_uri, $nonce, $pkce
);
# Store authorization in session for later token request
$c->session->{oidc_code}->{$code} = {
client_id => $client_id,
user => $user_claims,
scope => $scope,
redirect_uri => $redirect_uri,
nonce => $nonce,
code_challenge => $code_challenge,
code_challenge_method => $code_challenge ? 'S256' : undef,
};
# Clear the stored authorization request after resuming the flow.
delete $c->session->{oidc_auth_request};
# Build redirect URI with code and state
my $callback_uri = URI->new($redirect_uri);
$callback_uri->query_form(
code => $code,
state => $state,
);
$c->log->debug("Redirecting to: " . $callback_uri->as_string) if $config->{debug};
$c->response->redirect( $callback_uri->as_string );
}
=head2 token
POST /openidconnect/token
Token endpoint for exchanging authorization code for tokens.
Parameters (form-encoded):
- grant_type (REQUIRED): "authorization_code" or "refresh_token"
- code (REQUIRED for authorization_code): The authorization code
- redirect_uri (REQUIRED): Must match authorization request
- client_id (OPTIONAL): The client ID (extracted from code if not provided)
- client_secret (OPTIONAL): The client secret (required for confidential clients, optional for public clients)
Returns:
- access_token: The access token
- token_type: "Bearer"
- id_token: The ID token
- expires_in: Token expiration in seconds
- refresh_token: (optional) Refresh token
=cut
# Disable CSRF for compatibility with apps using Catalyst::Plugin::CSRF and auto_check => 1,
# which would otherwise reject POST requests to this endpoint without a valid CSRF token.
# The token endpoint is protected by client authentication and does not use cookies or sessions,
# so CSRF protection is not applicable.
sub token : Local DisableCSRF {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->response->content_type('application/json');
$c->log->debug('Token endpoint accessed') if $config->{debug};
my $grant_type = $c->request->params->{grant_type};
unless ($grant_type) {
$c->log->warn('Missing grant_type parameter');
return $self->_json_error( $c, 'invalid_request', 'grant_type is required' );
}
$c->log->debug("Token request with grant_type: $grant_type") if $config->{debug};
if ( $grant_type eq 'authorization_code' ) {
return $self->_handle_authorization_code_grant($c);
} elsif ( $grant_type eq 'refresh_token' ) {
return $self->_handle_refresh_token_grant($c);
} else {
$c->log->warn("Unsupported grant_type: $grant_type");
return $self->_json_error( $c, 'unsupported_grant_type', "Unsupported grant_type: $grant_type" );
}
}
=head2 userinfo
GET /openidconnect/userinfo
Authorization: Bearer <access_token>
UserInfo endpoint returning authenticated user's claims.
=cut
sub userinfo : Local {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('UserInfo endpoint accessed') if $config->{debug};
# Get bearer token
my $auth_header = $c->request->header('Authorization') || '';
my ($token) = $auth_header =~ /^Bearer\s+(\S+)$/;
unless ($token) {
$c->log->warn('Missing or invalid Authorization header');
return $self->_json_error( $c, 'invalid_token', 'Missing or invalid Authorization header' );
}
# Verify token
my $payload;
try {
$payload = $c->openidconnect->jwt->verify_token($token);
$c->log->debug('Access token verified successfully') if $config->{debug};
}
catch {
$c->log->warn("Token verification failed: $_");
return $self->_json_error( $c, 'invalid_token', "Token verification failed: $_" );
};
# Reject ID tokens and refresh tokens presented as access tokens (NEW-MED-1).
# Access tokens carry typ=at+JWT (RFC 9068); all other token types must be refused.
unless ( ( $payload->{typ} // '' ) eq 'at+JWT' ) {
$c->log->warn('Presented token is not an access token (typ=' . ($payload->{typ} // 'missing') . ')');
return $self->_json_error( $c, 'invalid_token', 'Presented token is not an access token' );
}
# Get user and claims
my $user_id = $payload->{sub};
unless ($user_id) {
$c->log->error('Token missing sub claim');
return $self->_json_error( $c, 'invalid_token', 'Token missing sub claim' );
}
$c->log->debug("UserInfo requested for user: $user_id") if $config->{debug};
# This would normally fetch the user from database
# For now, we'll use the claims already in the token
my %claims = (
sub => $payload->{sub},
);
# Add other standard claims from token
for my $claim (qw(name email email_verified picture phone_number phone_number_verified)) {
$claims{$claim} = $payload->{$claim} if exists $payload->{$claim};
}
$c->log->debug('UserInfo response prepared') if $config->{debug};
$self->_json_response( $c, \%claims );
}
=head2 logout
POST /openidconnect/logout
Logout endpoint to invalidate tokens and clear sessions.
Implements OpenID Connect RP-Initiated Logout 1.0.
Parameters:
- id_token_hint (REQUIRED when post_logout_redirect_uri is supplied): A
previously issued ID Token identifying the client requesting logout.
The token's signature is verified to confirm it was issued by this server.
Expiry is intentionally not checked; hint tokens are often expired.
- post_logout_redirect_uri (OPTIONAL): URL to redirect to after logout.
Must be registered in the client's C<post_logout_redirect_uris> list.
Providing this parameter without a valid C<id_token_hint> is rejected
with an C<invalid_request> error to prevent open-redirect attacks.
- state (OPTIONAL): Opaque value returned verbatim in the redirect query
string (only when post_logout_redirect_uri is also provided).
=cut
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' );
}
lib/Catalyst/Plugin/OpenIDConnect/Controller/Root.pm view on Meta::CPAN
$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) );
return slow_eq( $computed, $code_challenge );
}
# Normalise a redirect-URI config field.
# Accepts either an arrayref (YAML / JSON / Perl hash config) or a
# whitespace-delimited string (Config::General / Apache-style config).
# Returns a flat list of URI strings.
# Used for both redirect_uris and post_logout_redirect_uris so that both
# fields behave identically regardless of config format.
sub _normalize_uri_list {
my ($field) = @_;
return () unless defined $field;
return ref $field eq 'ARRAY' ? @$field : split /\s+/, $field;
}
=head2 jwks
GET /openidconnect/jwks
JSON Web Key Set endpoint for key discovery.
Returns the public key(s) for verifying signatures.
=cut
sub jwks : Local {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('JWKS endpoint accessed') if $config->{debug};
# Get JWT handler and public key
my $jwt = $c->openidconnect->jwt;
my $public_key = $jwt->public_key;
$c->log->debug('Extracting public key parameters for JWKS') if $config->{debug};
# Convert OpenSSL public key to Crypt::PK::RSA for easier parameter extraction
my $public_key_pem = $public_key->get_public_key_string();
my $pk = Crypt::PK::RSA->new(\$public_key_pem);
# Get key parameters for JWK generation
my $keydata = $pk->key2hash();
# Convert modulus and exponent to base64url
# Note: key2hash() returns lowercase keys (e, N, etc.)
my $n = $self->_bigint_to_base64url($keydata->{N});
my $e = $self->_bigint_to_base64url($keydata->{e});
# Create JWK with all required fields
my %jwk = (
kty => 'RSA',
use => 'sig',
kid => $jwt->key_id,
alg => 'RS256',
n => $n,
e => $e,
);
$c->log->debug('JWKS response prepared with key ID: ' . $jwt->key_id) if $config->{debug};
$self->_json_response( $c, { keys => [ \%jwk ] } );
}
# Private helper methods
sub _handle_authorization_code_grant {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('Processing authorization_code grant') if $config->{debug};
my $code = $c->request->params->{code};
my $redirect_uri = $c->request->params->{redirect_uri};
my $client_id = $c->request->params->{client_id};
my $client_secret = $c->request->params->{client_secret};
my $code_verifier = $c->request->params->{code_verifier};
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});
# Log token lengths for debugging signature issues
$c->log->debug("ID token length: " . length($id_token) . " bytes") if $config->{debug};
$c->log->debug("Access token length: " . length($access_token) . " bytes") if $config->{debug};
$c->log->debug("Refresh token length: " . length($refresh_token) . " bytes") if $config->{debug};
$c->log->debug("ID token (first 80 chars): " . substr($id_token, 0, 80) . "...") if $config->{debug};
# Return tokens
$self->_json_response( $c, {
access_token => $access_token,
token_type => 'Bearer',
id_token => $id_token,
expires_in => 3600,
refresh_token => $refresh_token,
});
}
sub _handle_refresh_token_grant {
my ( $self, $c ) = @_;
my $config = $c->openidconnect->config;
$c->log->debug('Processing refresh_token grant') if $config->{debug};
my $refresh_token = $c->request->params->{refresh_token};
my $client_id = $c->request->params->{client_id};
my $client_secret = $c->request->params->{client_secret};
unless ( $refresh_token && $client_id && $client_secret ) {
$c->log->warn('Missing required parameters for refresh token grant');
return $self->_json_error( $c, 'invalid_request', 'Missing required parameters' );
}
$c->log->debug("Refresh token request for client: $client_id") if $config->{debug};
# Verify client
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' );
}
# Verify refresh token
my $payload;
( run in 0.541 second using v1.01-cache-2.11-cpan-9581c071862 )