Catalyst-Plugin-OpenIDConnect

 view release on metacpan or  search on metacpan

lib/Catalyst/Plugin/OpenIDConnect/Utils/JWT.pm  view on Meta::CPAN

        ));
    }

    # 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
        );

        $self->logger->debug('JWT signature verified') if $self->logger;

        # Decode payload
        my $payload_json = _urlsafe_b64_decode($payload_b64);
        my $payload = decode_json($payload_json);

        $self->logger->debug('JWT payload decoded successfully') if $self->logger;

        # --- Mandatory claim validation (RFC 7519 §4.1, OIDC Core §2) ---
        # exp and iss must be present and valid; a token that omits them
        # must be rejected regardless of its signature.
        die 'Missing exp claim' unless defined $payload->{exp};
        die 'Token expired'     if $payload->{exp} < time();
        die 'Missing iss claim' unless defined $payload->{iss};
        die 'Invalid issuer'    unless $payload->{iss} eq $self->issuer;

        # nbf (not-before) is optional but must be honoured when present
        die 'Token not yet valid'
            if defined $payload->{nbf} && $payload->{nbf} > time();

        # aud — validated when the caller supplies an expected value
        if ( defined $expected_audience ) {
            die 'Missing aud claim' unless defined $payload->{aud};
            die 'Invalid audience'  unless $payload->{aud} eq $expected_audience;
        }

        $self->logger->debug('JWT claims validated') if $self->logger;

        return $payload;
    }
    catch {
        $self->logger->warn("Token verification failed: $_") if $self->logger;
        die "Token verification failed: $_";
    };
}

=head2 create_id_token(%claims)

Creates a signed ID token with the specified claims.

The following claims are automatically set if not provided:
- exp: Expiration time (1 hour from now, required by OIDC/JWT spec)
- iat: Issued at time (current time)
- iss: Issuer URL

=cut

sub create_id_token {
    my ( $self, %claims ) = @_;

    # Ensure exp is set - this is MANDATORY per RFC 7519 and OIDC spec
    # If not provided, default to 1 hour from now
    unless ( defined $claims{exp} ) {
        $claims{exp} = time() + 3600;
    }

    my %payload = (
        typ => 'JWT',
        %claims,
    );

    return $self->sign_token(%payload);
}

=head2 create_access_token(%claims)

Creates a signed access token with the specified claims.



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