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 )