Catalyst-Plugin-OpenIDConnect
view release on metacpan or search on metacpan
lib/Catalyst/Plugin/OpenIDConnect/Utils/JWT.pm view on Meta::CPAN
package Catalyst::Plugin::OpenIDConnect::Utils::JWT;
use strict;
use warnings;
use Moose;
use namespace::autoclean;
use JSON::MaybeXS qw(encode_json decode_json);
use MIME::Base64 qw(encode_base64 decode_base64);
use Digest::SHA qw(sha256);
use Crypt::OpenSSL::RSA;
use DateTime;
use Try::Tiny;
=head1 NAME
Catalyst::Plugin::OpenIDConnect::Utils::JWT - JWT handling for OpenID Connect
=head1 DESCRIPTION
Provides JWT signing and verification functionality using RS256 (RSA SHA-256) algorithm
for OpenID Connect token creation and validation.
=head1 ATTRIBUTES
=head2 private_key
The RSA private key for signing tokens.
=cut
has private_key => (
is => 'ro',
isa => 'Crypt::OpenSSL::RSA',
required => 1,
);
=head2 public_key
The RSA public key for verifying tokens.
=cut
has public_key => (
is => 'ro',
isa => 'Crypt::OpenSSL::RSA',
required => 1,
);
=head2 key_id
The key ID (kid) used in JWT headers.
=cut
has key_id => (
is => 'ro',
isa => 'Str',
required => 1,
);
=head2 issuer
The issuer URL/identifier for the iss claim.
=cut
has issuer => (
is => 'ro',
isa => 'Str',
required => 1,
);
=head2 logger
Optional logger instance for debug/info logging.
=cut
has logger => (
is => 'ro',
isa => 'Maybe[Object]',
required => 0,
);
=head1 METHODS
=head2 sign_token(%payload)
Signs a JWT token with the configured private key using RS256 algorithm.
Returns the complete JWT (header.payload.signature).
=cut
sub sign_token {
my ( $self, %payload ) = @_;
$self->logger->debug('Signing JWT token') if $self->logger;
# Set standard claims
$payload{iss} = $self->issuer unless defined $payload{iss};
$payload{iat} = time() unless defined $payload{iat};
# Log only non-sensitive metadata â never log PII-bearing claims (MED-2).
if ( $self->logger ) {
$self->logger->debug( sprintf(
'Signing JWT: sub=%s aud=%s exp=%s',
$payload{sub} // '?', $payload{aud} // '?', $payload{exp} // '?',
));
}
# Prep header
my %header = (
alg => 'RS256',
typ => 'JWT',
kid => $self->key_id,
);
# Encode header and payload
my $header_json = encode_json( \%header );
# Perl's JSON serialiser encodes a scalar as a JSON string if the SvPOK
# (string) flag is set, even when the value is also numeric. Reading a
# number through a string context â e.g. the sprintf() debug statement
# above â sets that flag. Explicitly numify all timestamp claims with
# int() to clear SvPOK before serialisation so they are always encoded
# as JSON integers (e.g. 1746000000, not "1746000000"). Python's authlib and
# other compliant RPs reject string-typed exp/iat/nbf values.
$payload{$_} = int( $payload{$_} )
for grep { defined $payload{$_} } qw(exp iat nbf);
my $payload_json = encode_json( \%payload );
my $header_b64 = _urlsafe_b64_encode($header_json);
my $payload_b64 = _urlsafe_b64_encode($payload_json);
# Create signature (explicitly use SHA256 for RS256)
my $signing_input = "$header_b64.$payload_b64";
my $priv_key = $self->private_key;
# Ensure consistent RSA configuration for signing (RFC 3447 PKCS1v15)
$priv_key->use_pkcs1_padding();
$priv_key->use_sha256_hash();
my $signature = $priv_key->sign($signing_input);
my $signature_b64 = _urlsafe_b64_encode($signature);
my $token = "$signing_input.$signature_b64";
if ( $self->logger ) {
$self->logger->debug( sprintf(
'JWT signed: header_b64_len=%d, payload_b64_len=%d, sig_b64_len=%d, total_len=%d',
length($header_b64), length($payload_b64), length($signature_b64), length($token),
));
$self->logger->debug("JWT token (first 80 chars): " . substr($token, 0, 80) . "...");
}
return $token;
}
=head2 verify_token($token, %opts)
Verifies a JWT token with the configured public key.
Mandatory claims C<exp> and C<iss> are always validated. The C<nbf>
claim is validated when present. Pass C<expected_audience> to also
validate the C<aud> claim:
$jwt->verify_token($token, expected_audience => 'my-client-id');
Returns a hashref with decoded claims on success.
Raises an exception on verification failure.
=cut
sub verify_token {
my ( $self, $token, %opts ) = @_;
my $expected_audience = $opts{expected_audience};
$self->logger->debug('Verifying JWT token') if $self->logger;
return try {
my @parts = split /\./, $token;
die 'Invalid JWT format' unless @parts == 3;
my ( $header_b64, $payload_b64, $signature_b64 ) = @parts;
$self->logger->debug('JWT format validated (3 parts)') if $self->logger;
# Verify signature (explicitly use SHA256 for RS256)
my $signing_input = "$header_b64.$payload_b64";
my $signature = _urlsafe_b64_decode($signature_b64);
my $pub_key = $self->public_key;
# Ensure consistent RSA configuration for verification (RFC 3447 PKCS1v15)
$pub_key->use_pkcs1_padding();
$pub_key->use_sha256_hash();
die 'Invalid signature' unless $pub_key->verify(
$signing_input,
$signature
);
( run in 0.865 second using v1.01-cache-2.11-cpan-13bb782fe5a )