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 )