Catalyst-Plugin-OpenIDConnect

 view release on metacpan or  search on metacpan

DEPLOYMENT.md  view on Meta::CPAN

Note: For production, consider using 4096-bit keys or storing keys in a HSM (Hardware Security Module).

### 3. Configure Your Application

Create/update `catalyst.conf`:

```
<Plugin::OpenIDConnect>
    <issuer>
        url = https://auth.example.com
        private_key_file = /secure/path/private.pem
        public_key_file = /secure/path/public.pem
        key_id = prod-key-2024-01
    </issuer>
    
    <clients>
        <my-app>
            client_secret = <randomly-generated-secret>
            redirect_uris = https://app.example.com/callback https://app.example.com/oauth/callback
            post_logout_redirect_uris = https://app.example.com/logged-out
            response_types = code

DEPLOYMENT.md  view on Meta::CPAN


    <store_args>
        server   = 127.0.0.1:6379
        prefix   = myapp:oidc:code:
        code_ttl = 600
        # password = <redis-auth-password>   # omit if no AUTH required
    </store_args>

    <issuer>
        url              = https://auth.example.com
        private_key_file = /secure/path/private.pem
        public_key_file  = /secure/path/public.pem
        key_id           = prod-key-2024-01
    </issuer>
    ...
</Plugin::OpenIDConnect>
```

**Perl hash config (e.g. `MyApp.pm`):**

```perl

IMPLEMENTATION_GUIDE.md  view on Meta::CPAN

- Not accessible to browser (HTTP-only cookies in production)

## Configuration

### Issuer Configuration

```perl
<Plugin::OpenIDConnect>
    <issuer>
        url = http://localhost:5000
        private_key_file = /path/to/private.pem
        public_key_file = /path/to/public.pem
        key_id = my-key-123
    </issuer>
```

**Fields:**
- `url` - The issuer identifier (in iss claim)
- `private_key_file` - Path to RSA private key (PEM format)
- `public_key_file` - Path to RSA public key (optional, derived from private)
- `key_id` - Key identifier for JWK Set

### Client Configuration

```perl
<clients>
    <my-client>
        client_secret             = secret123
        redirect_uris             = http://app.example.com/callback

QUICKSTART.md  view on Meta::CPAN

    OpenIDConnect
    Session
    Session::Store::File
    Session::State::Cookie
/;

__PACKAGE__->config(
    'Plugin::OpenIDConnect' => {
        issuer => {
            url                => 'http://localhost:5000',
            private_key_file   => '/path/to/private.pem',
            public_key_file    => '/path/to/public.pem',
            key_id             => 'my-key-1',
        },
        clients => {
            'my-client' => {
                client_secret             => 'my-secret',
                redirect_uris             => ['http://localhost:3000/callback'],
                post_logout_redirect_uris => ['http://localhost:3000/logged-out'],
            },
        },

README.md  view on Meta::CPAN

# Load the controller before setup
use MyApp::Controller::OpenIDConnect;
```

### 3. Configure in your catalyst.conf

```
<Plugin::OpenIDConnect>
    <issuer>
        url = http://localhost:5000
        private_key_file = /path/to/private_key.pem
        public_key_file = /path/to/public_key.pem
        key_id = my-key-123
    </issuer>
    
    <clients>
        <MyClient>
            client_id = my-client-id
            client_secret = my-client-secret
            redirect_uris = http://localhost:3000/callback
            post_logout_redirect_uris = http://localhost:3000/logged-out

README.md  view on Meta::CPAN

GET /.well-known/openid-configuration
```

Returns the OpenID Connect provider configuration in JSON format.

## Configuration Reference

### Issuer Configuration

- `url`: The issuer URL (used as 'iss' claim in tokens)
- `private_key_file`: Path to RSA private key for signing tokens
- `public_key_file`: Path to RSA public key for verification (auto-derived from private key if not provided)
- `key_id`: Key identifier (used in JWT header)

### Client Configuration

- `client_id`: Unique client identifier
- `client_secret`: Client secret for token endpoint
- `redirect_uris`: Arrayref or whitespace-separated string of URIs the client is permitted to redirect to after authorization. At least one entry is required.
- `post_logout_redirect_uris`: Arrayref or whitespace-separated string of URIs the client is permitted to redirect to after logout. Required when the client will use `post_logout_redirect_uri` at the logout endpoint.
- `response_types`: Space-separated response types (e.g., "code" or "code id_token")

example/app.pl  view on Meta::CPAN

__PACKAGE__->config(
    name => 'OIDCExample',
    
    # Disable deprecated actions
    'disable_component_resolution_regex_fallback' => 1,
    
    # OpenID Connect configuration
    'Plugin::OpenIDConnect' => {
        issuer => {
            url                => 'http://localhost:5000',
            private_key_file   => 'example/keys/private.pem',
            public_key_file    => 'example/keys/public.pem',
            key_id             => 'example-key-1',
        },
        
        # Client configurations
        clients => {
            'example-client' => {
                client_secret             => 'example-client-secret',
                redirect_uris             => ['http://localhost:3000/callback'],
                post_logout_redirect_uris => ['http://localhost:3000/logged-out'],

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

        OpenIDConnect
        Session
        Session::Store::File
        Session::State::Cookie
    /;

    MyApp->config(
        'Plugin::OpenIDConnect' => {
            issuer => {
                url => 'http://localhost:5000',
                private_key_file => '/path/to/private.pem',
                public_key_file => '/path/to/public.pem',
                key_id => 'key-123',
            },
            clients => {
                'my-client' => {
                    client_secret => 'secret123',
                    redirect_uris => ['http://localhost:3000/callback'],
                    response_types => ['code'],
                    grant_types => ['authorization_code'],
                    scope => 'openid profile email',

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

=cut

after 'setup' => sub {
    my ($app) = @_;
    
    my $config = $app->config->{'Plugin::OpenIDConnect'} || {};
    
    $app->log->debug('OpenID Connect plugin setup starting') if $config->{debug};
    
    # Only initialize if properly configured
    if ( $config->{issuer} && $config->{issuer}{private_key_file} ) {
        try {
            $app->log->debug('Initializing OpenID Connect with issuer: ' . $config->{issuer}{url}) if $config->{debug};
            
            # Create JWT handler
            my $jwt = $app->_oidc_build_jwt_handler($config);
            $app->_oidc_jwt($jwt);
            $app->log->debug('JWT handler initialized successfully') if $config->{debug};
            
            # Create store - class and constructor args are configurable so that
            # shared-memory backends (e.g. Redis) can be used under FastCGI.

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

                unless $store->DOES('Catalyst::Plugin::OpenIDConnect::Role::Store');

            $app->_oidc_store($store);
            $app->log->debug("State store initialized ($store_class)") if $config->{debug};
        }
        catch {
            $app->log->error("Failed to initialize OpenID Connect plugin: $_");
            die $_;
        };
    } else {
        $app->log->warn('OpenID Connect plugin not configured (missing issuer.private_key_file)');
    }
};

before setup_finalize => sub {
    my ($app) = @_;
    my $config = $app->config->{'Plugin::OpenIDConnect'} || {};
    $config->{debug} ||= $app->debug;
};

=head2 openidconnect()

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

    my ( $c, $config ) = @_;

    $c->log->debug('Building JWT handler from configuration') if $config->{debug};

    my $issuer_cfg = $config->{issuer} || {};
    my $issuer_url = $issuer_cfg->{url} || $c->uri_for('/')->as_string;

    $c->log->debug("JWT issuer URL: $issuer_url") if $config->{debug};

    # Load private key
    my $private_key_file = $issuer_cfg->{private_key_file}
        or die 'issuer.private_key_file is required';

    $c->log->debug("Loading private key from: $private_key_file") if $config->{debug};
    
    open my $key_fh, '<', $private_key_file
        or die "Cannot read private key file: $!";
    my $key_data = do { local $/; <$key_fh> };
    close $key_fh;

    my $private_key = Crypt::OpenSSL::RSA->new_private_key($key_data);
    $c->log->debug('Private key loaded successfully') if $config->{debug};

    # Load or derive public key
    my $public_key;
    if ( my $public_key_file = $issuer_cfg->{public_key_file} ) {
        $c->log->debug("Loading public key from: $public_key_file") if $config->{debug};
        
        open $key_fh, '<', $public_key_file
            or die "Cannot read public key file: $!";
        my $pub_data = do { local $/; <$key_fh> };
        close $key_fh;
        $public_key = Crypt::OpenSSL::RSA->new_public_key($pub_data);
        $c->log->debug('Public key loaded successfully') if $config->{debug};
    } else {
        $c->log->debug('Deriving public key from private key') if $config->{debug};
        # Extract public key from private key
        $public_key = Crypt::OpenSSL::RSA->new_public_key(
            $private_key->get_public_key_string()
        );
        $c->log->debug('Public key derived successfully') if $config->{debug};
    }

    my $key_id = $issuer_cfg->{key_id} || 'default';
    $c->log->debug("Using key ID: $key_id") if $config->{debug};

    return Catalyst::Plugin::OpenIDConnect::Utils::JWT->new(
        private_key => $private_key,
        public_key  => $public_key,
        key_id      => $key_id,
        issuer      => $issuer_url,
        logger      => $c->log,
    );
}

1;

=head1 AUTHOR

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

Returns the JWT handler instance.

=cut

sub jwt {
    my ($self) = @_;
    $self->catalyst->log->debug('Retrieving JWT handler') if $self->config->{debug};
    my $jwt = $self->catalyst->_oidc_jwt();
    unless ($jwt) {
        $self->catalyst->log->error('OpenID Connect JWT handler not initialized');
        die 'OpenID Connect JWT handler not initialized. Check your Plugin::OpenIDConnect configuration (issuer.private_key_file and issuer.public_key_file required).';
    }
    return $jwt;
}

=head2 store()

Returns the state store instance.

=cut

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


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

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

    $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(

t/01_jwt.t  view on Meta::CPAN

use Crypt::OpenSSL::RSA;
use JSON::MaybeXS qw(encode_json);
use MIME::Base64  qw(encode_base64);

# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------

my $rsa = Crypt::OpenSSL::RSA->generate_key(1024);

my $private_key = $rsa;
my $public_key = Crypt::OpenSSL::RSA->new_public_key(
    $rsa->get_public_key_string()
);

my $jwt = Catalyst::Plugin::OpenIDConnect::Utils::JWT->new(
    private_key => $private_key,
    public_key  => $public_key,
    key_id      => 'test-key',
    issuer      => 'http://localhost:5000',
);

ok($jwt, 'JWT handler created');

# ---------------------------------------------------------------------------
# Helper: build a validly-signed JWT with exactly the given payload.
# Unlike sign_token(), this does NOT auto-set iss/iat so we can test

t/01_jwt.t  view on Meta::CPAN

sub _raw_jwt {
    my (%payload) = @_;
    my $encode = sub {
        my $b64 = encode_base64($_[0], '');
        $b64 =~ tr|+/=|-_|d;
        return $b64;
    };
    my $header      = $encode->(encode_json({ alg => 'RS256', typ => 'JWT', kid => 'test-key' }));
    my $body        = $encode->(encode_json(\%payload));
    my $signing_in  = "$header.$body";
    $private_key->use_sha256_hash();
    my $sig = $encode->($private_key->sign($signing_in));
    return "$signing_in.$sig";
}

# ---------------------------------------------------------------------------
# Basic signing and verification (happy path)
# ---------------------------------------------------------------------------

my %payload = (
    sub   => 'user-123',
    name  => 'Test User',

t/01_jwt.t  view on Meta::CPAN


{
    {
        package CapturingLogger;
        sub new   { bless { msgs => [] }, shift }
        sub debug { push @{ $_[0]{msgs} }, $_[1] }
    }
    my $cap_logger = CapturingLogger->new();

    my $logging_jwt = Catalyst::Plugin::OpenIDConnect::Utils::JWT->new(
        private_key => $private_key,
        public_key  => $public_key,
        key_id      => 'test-key',
        issuer      => 'http://localhost:5000',
        logger      => $cap_logger,
    );

    $logging_jwt->sign_token(
        sub   => 'uid-42',
        aud   => 'my-client',
        exp   => time() + 3600,

t/03_plugin.t  view on Meta::CPAN

use Catalyst::Plugin::OpenIDConnect::Context;
use Catalyst::Plugin::OpenIDConnect::Utils::JWT;
use Catalyst::Plugin::OpenIDConnect::Utils::Store;
use Crypt::OpenSSL::RSA;
use File::Temp qw(tempfile);
use JSON::MaybeXS qw(encode_json);

# Generate test keys
my $rsa = Crypt::OpenSSL::RSA->generate_key(1024);

my $private_key = $rsa;
my $public_key = Crypt::OpenSSL::RSA->new_public_key(
    $rsa->get_public_key_string()
);

# Create JWT handler for testing context methods
my $jwt = Catalyst::Plugin::OpenIDConnect::Utils::JWT->new(
    private_key => $private_key,
    public_key  => $public_key,
    key_id      => 'test-key',
    issuer      => 'http://localhost:5000',
);

ok($jwt, 'JWT handler created');

# Create store for testing context methods
my $store = Catalyst::Plugin::OpenIDConnect::Utils::Store->new();
ok($store, 'Store created');

t/03_plugin.t  view on Meta::CPAN

# MED-3: JWT and Store instances are isolated per consuming application class
# ---------------------------------------------------------------------------

{
    my $app_a = bless {}, 'FakeAppAlpha';
    my $app_b = bless {}, 'FakeAppBeta';

    # Create a second distinct JWT instance for app_b
    my $rsa_b = Crypt::OpenSSL::RSA->generate_key(1024);
    my $jwt_b = Catalyst::Plugin::OpenIDConnect::Utils::JWT->new(
        private_key => $rsa_b,
        public_key  => Crypt::OpenSSL::RSA->new_public_key( $rsa_b->get_public_key_string() ),
        key_id      => 'key-b',
        issuer      => 'http://b.example.com',
    );

    Catalyst::Plugin::OpenIDConnect::_oidc_jwt( $app_a, $jwt );
    Catalyst::Plugin::OpenIDConnect::_oidc_jwt( $app_b, $jwt_b );

    is(
        Catalyst::Plugin::OpenIDConnect::_oidc_jwt($app_a), $jwt,

t/05_logout.t  view on Meta::CPAN


use Catalyst::Plugin::OpenIDConnect::Utils::JWT;
use Catalyst::Plugin::OpenIDConnect::Controller::Root;
use Crypt::OpenSSL::RSA;

# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------

my $rsa = Crypt::OpenSSL::RSA->generate_key(1024);
my $private_key = $rsa;
my $public_key  = Crypt::OpenSSL::RSA->new_public_key( $rsa->get_public_key_string() );

my $jwt = Catalyst::Plugin::OpenIDConnect::Utils::JWT->new(
    private_key => $private_key,
    public_key  => $public_key,
    key_id      => 'test-key',
    issuer      => 'https://auth.example.com',
);

# ---------------------------------------------------------------------------
# JWT::decode_id_token_hint — valid (non-expired) token
# ---------------------------------------------------------------------------

my $id_token = $jwt->create_id_token(

t/05_logout.t  view on Meta::CPAN


is( $jwt->decode_id_token_hint($tampered_token), undef,
    'decode_id_token_hint: returns undef for a tampered token' );

# ---------------------------------------------------------------------------
# JWT::decode_id_token_hint — token from a different key must be rejected
# ---------------------------------------------------------------------------

my $other_rsa = Crypt::OpenSSL::RSA->generate_key(1024);
my $other_jwt = Catalyst::Plugin::OpenIDConnect::Utils::JWT->new(
    private_key => $other_rsa,
    public_key  => Crypt::OpenSSL::RSA->new_public_key( $other_rsa->get_public_key_string() ),
    key_id      => 'other-key',
    issuer      => 'https://auth.example.com',
);

my $foreign_token = $other_jwt->create_id_token(
    sub => 'user-789',
    aud => 'test-client',
    exp => time() + 3600,
);



( run in 1.607 second using v1.01-cache-2.11-cpan-13bb782fe5a )