Catalyst-Plugin-OAuth2-AuthorizationServer

 view release on metacpan or  search on metacpan

examples/README.md  view on Meta::CPAN

# OAuth 2.0 authorization-server example

A minimal Catalyst app that mounts the plugin's authorization-server endpoints
(metadata, dynamic client registration, authorize, token) with an in-memory
`Store` and a fixed-user auto-consent hook. The client walks the full
authorization-code + PKCE (S256) flow: it dynamically registers a client,
drives the `/oauth/authorize` request, captures the redirected `code`, and
exchanges it at `/oauth/token` for a JWT access token.

## Run it

```
plackup -p 5000 examples/app.psgi

examples/lib/Example/OAuthAS.pm  view on Meta::CPAN


our $VERSION = '0.001';

__PACKAGE__->config(
    'Catalyst::Plugin::OAuth2::AuthorizationServer' => {
        store                 => 'Store',   # resolved via $c->model('Store')
        signing_key           => 'example-authorization-server-signing-key-0123456789',
        issuer                => 'http://localhost:5000',
        resource              => 'urn:example:resource',
        scopes_supported      => [ 'example:read', 'example:write' ],
        authorize_endpoint    => 'http://localhost:5000/oauth/authorize',
        token_endpoint        => 'http://localhost:5000/oauth/token',
        registration_endpoint => 'http://localhost:5000/oauth/register',
    },
);

# The plugin calls this after a valid /authorize request. A real app would render
# a login + consent page; this example auto-consents as a fixed demo user, mints
# the code, and redirects back to the client with it.
sub oauth_authenticate ( $c, $request_id ) {
    my $out = $c->oauth_issue_code( 'demo-user', $request_id );
    return unless $out;
    my $uri = URI->new( $out->{redirect_uri} );

lib/Catalyst/Plugin/OAuth2/AuthorizationServer.pm  view on Meta::CPAN

collapsed to a single value (RFC 6749 3.2.1).

=head1 METHODS

=head2 oauth_metadata

Render the RFC 8414 Authorization Server Metadata document as C<200 application/json>.

=head2 oauth_register

Dynamic Client Registration endpoint (RFC 7591). Calls the optional app hook
C<oauth_dcr_allow_registration($c)> first: if it returns false, responds 429.
Reads a JSON body, calls the engine's C<register_client>, and writes C<201>
JSON with C<Cache-Control: no-store>. Metadata that asks for something the AS
metadata does not advertise is rejected with C<invalid_client_metadata>; see
L<Catalyst::Plugin::OAuth2::AuthorizationServer::Server/register_client> for
exactly what is enforced.

=head2 oauth_authorize

Validates the authorize query parameters via the engine. On success, calls the

lib/Catalyst/Plugin/OAuth2/AuthorizationServer.pm  view on Meta::CPAN

is revoked, including the one the legitimate client currently holds. Reuse
detection depends on the Store retaining rotated tokens until they expire; see
C<rotate_refresh_token> in
L<Catalyst::Plugin::OAuth2::AuthorizationServer::Role::Store>.

B<Security limitation:> revoking the family does B<not> kill access tokens
already minted from it. They are stateless JWTs, verified without consulting
the Store, and stay valid until C<access_ttl> elapses. Keep C<access_ttl>
short. Access tokens carry a C<jti> claim so a denylist can be added later
without changing the token format, but this plugin implements no denylist and
no RFC 7009 revocation endpoint.

A concurrent double-refresh (the same token presented twice at once, with no
attacker involved) is indistinguishable from a replay and will revoke the
family. This is inherent to RFC 9700 reuse detection. Both requests fail: the
one that lost the race is rejected as a replay, and the one still in flight is
refused when it tries to persist its successor into the now-revoked family, so
it answers C<invalid_grant> rather than surviving with a live token. The client
must start a new authorization.

Apps can call C<revoke_refresh_tokens_for_subject> on logout/deactivation.

Pruning revoked refresh tokens after they expire is the host application's
responsibility, as is garbage-collecting abandoned Dynamic Client
Registrations (clients that never completed a token exchange): the Store has
the visibility to identify and remove them. This plugin tracks no client
usage.

=head1 EXAMPLES

A runnable example lives in F<examples/>: a small Catalyst app exposing the
metadata, dynamic client registration, authorize, and token endpoints backed
by an in-memory store, plus a core-Perl client that drives the full
authorization-code + PKCE flow. Start it with C<plackup examples/app.psgi>
and run C<perl examples/client.pl>. See F<examples/README.md>.

=head1 AUTHOR

Mike Whitaker <mike@altrion.org>

Built with tool assistance from Claude Code/(mostly) Opus 4.8 to accelerate
code generation and maximise test coverage (and reduce typing :D).

lib/Catalyst/Plugin/OAuth2/AuthorizationServer/Server.pm  view on Meta::CPAN

has jwt_alg     => ( is => 'ro', default => 'HS256' );
has access_ttl  => ( is => 'ro', default => 900 );
has refresh_ttl => ( is => 'ro', default => 2592000 );
has code_ttl    => ( is => 'ro', default => 60 );

has scopes_supported        => ( is => 'ro' );          # arrayref or undef
has metadata_max_bytes      => ( is => 'ro', default => 8192 );
has redirect_uris_max       => ( is => 'ro', default => 5 );
has redirect_uri_max_length => ( is => 'ro', default => 2048 );

has authorize_endpoint    => ( is => 'lazy' );
has token_endpoint        => ( is => 'lazy' );
has registration_endpoint => ( is => 'lazy' );

sub _build_authorize_endpoint    ( $self ) { $self->issuer . '/authorize' }
sub _build_token_endpoint        ( $self ) { $self->issuer . '/token' }
sub _build_registration_endpoint ( $self ) { $self->issuer . '/register' }

sub BUILD ( $self, $args ) {
    Carp::croak 'resource must be a non-empty scalar or arrayref'
        unless @{ $self->_resource_list };
    for my $ttl (qw/access_ttl refresh_ttl code_ttl/) {
        my $v = $self->$ttl;
        Carp::croak "$ttl must be a positive integer"
            if !$v || $v <= 0;
    }
    state %ALLOWED_ALG = map { $_ => 1 } qw/HS256 HS384 HS512/;

lib/Catalyst/Plugin/OAuth2/AuthorizationServer/Server.pm  view on Meta::CPAN

    Catalyst::Plugin::OAuth2::AuthorizationServer::Error->throw(
        error             => 'invalid_client_metadata',
        error_description => $desc,
        http_status       => 400,
    );
}

sub metadata_document ( $self ) {
    my %doc = (
        issuer                                => $self->issuer,
        authorization_endpoint                => $self->authorize_endpoint,
        token_endpoint                        => $self->token_endpoint,
        registration_endpoint                 => $self->registration_endpoint,
        response_types_supported              => ['code'],
        grant_types_supported                 => [ 'authorization_code', 'refresh_token' ],
        code_challenge_methods_supported      => ['S256'],
        token_endpoint_auth_methods_supported => ['none'],
    );
    $doc{scopes_supported} = $self->scopes_supported if $self->scopes_supported;
    return \%doc;
}

sub register_client ( $self, $metadata ) {
    my $uris = $metadata->{redirect_uris};
    $self->_invalid_metadata('redirect_uris is required')
        unless ref $uris eq 'ARRAY' && @$uris;
    $self->_invalid_metadata('too many redirect_uris')

lib/Catalyst/Plugin/OAuth2/AuthorizationServer/Server.pm  view on Meta::CPAN


# RFC 7591 3.2.1: reject a registration whose metadata asks for something this
# AS does not support. The allow-lists are read straight off metadata_document,
# so registration can never accept a value discovery does not advertise. Fields
# RFC 7591 leaves free-form (client_name, logo_uri, contacts, extensions, and
# scope when no scopes_supported is configured) are left alone: a value is only
# rejected where the AS has actually declared what it supports.
sub _validate_client_metadata ( $self, $metadata ) {
    my $doc = $self->metadata_document;

    if ( exists $metadata->{token_endpoint_auth_method} ) {
        my $method = $metadata->{token_endpoint_auth_method};
        my %ok = map { $_ => 1 }
            @{ $doc->{token_endpoint_auth_methods_supported} };
        $self->_invalid_metadata('unsupported token_endpoint_auth_method')
            if ref $method || !defined $method || !$ok{$method};
    }

    for my $field (qw/grant_types response_types/) {
        next unless exists $metadata->{$field};
        my $values = $metadata->{$field};
        $self->_invalid_metadata(
            "$field must be a non-empty array of strings")
            unless ref $values eq 'ARRAY' && @$values;
        my %ok = map { $_ => 1 } @{ $doc->{"${field}_supported"} };

lib/Catalyst/Plugin/OAuth2/AuthorizationServer/Server.pm  view on Meta::CPAN


Per RFC 7591 3.2.1, registration also rejects metadata asking for anything
this AS does not support, with C<invalid_client_metadata>. The allow-lists are
taken from L</metadata_document>, so registration can never accept a value the
discovery document does not advertise:

=over

=item *

C<token_endpoint_auth_method> must be one of
C<token_endpoint_auth_methods_supported> (C<none>: this profile registers
public PKCE clients, so C<client_secret_basic> and friends are rejected).

=item *

C<grant_types> must be a non-empty arrayref, each value one of
C<grant_types_supported> (C<authorization_code>, C<refresh_token>).

=item *

C<response_types> must be a non-empty arrayref, each value one of

t/catalyst-app.t  view on Meta::CPAN

use Catalyst::Test 'TestApp';

my $KEY = 'integration-signing-key-0123456789';

# --- AS metadata ---
{
    my $res = request( GET '/.well-known/oauth-authorization-server' );
    ok( $res->is_success, 'metadata 200' );
    my $m = decode_json( $res->content );
    is_deeply( $m->{code_challenge_methods_supported}, ['S256'], 'S256 advertised' );
    is( $m->{registration_endpoint}, 'http://localhost/oauth/register',
        'registration endpoint' );
}

# --- DCR ---
my $client_id;
{
    my $res = request( POST '/oauth/register',
        'Content-Type' => 'application/json',
        Content => encode_json({ redirect_uris => ['https://app/cb'] }) );
    is( $res->code, 201, 'register 201' );
    $client_id = decode_json( $res->content )->{client_id};

t/catalyst-app.t  view on Meta::CPAN

        . '&redirect_uri=https://evil/cb&response_type=code'
        . "&code_challenge=$challenge&code_challenge_method=S256"
        . '&resource=https://rs/mcp&state=dup2' );
    is( $res->code, 400, 'duplicated redirect_uri -> 400, not a redirect' );
    is( $res->header('Location'), undef,
        'duplicated redirect_uri produces no Location header (no open redirect)' );
    is( decode_json( $res->content )->{error}, 'invalid_request',
        'duplicated redirect_uri is invalid_request' );
}

# same rule on the token endpoint (body parameters)
{
    my $res = request( POST '/oauth/token', [
        grant_type => 'authorization_code',
        grant_type => 'refresh_token',
        refresh_token => 'whatever',
    ] );
    is( $res->code, 400, 'duplicated grant_type on token -> 400' );
    is( decode_json( $res->content )->{error}, 'invalid_request',
        'duplicated token param is invalid_request' );
}

t/lib/TestApp.pm  view on Meta::CPAN


our $VERSION = '0.001';

__PACKAGE__->config(
    'Catalyst::Plugin::OAuth2::AuthorizationServer' => {
        store                 => 'OAuthStore',     # resolved via $c->model
        signing_key           => 'integration-signing-key-0123456789',
        issuer                => 'http://localhost',
        resource              => 'https://rs/mcp',
        scopes_supported      => [ 'example:read', 'example:themes:write' ],
        authorize_endpoint    => 'http://localhost/oauth/authorize',
        token_endpoint        => 'http://localhost/oauth/token',
        registration_endpoint => 'http://localhost/oauth/register',
    },
);

# --- app-provided hooks (called by the plugin) ---

# authn/consent hook: a real app 302s to its SPA; here we auto-consent as
# user-1 and immediately mint + redirect with the code.
sub oauth_authenticate ( $c, $request_id ) {
    my $out = $c->oauth_issue_code( 'user-1', $request_id );
    return unless $out;

t/server-dcr.t  view on Meta::CPAN

# fragment rejected
{
    my $e = exception {
        engine->register_client({ redirect_uris => ['https://app/cb#frag'] });
    };
    is( $e->error, 'invalid_client_metadata', 'redirect_uri with fragment rejected' );
}

# --- RFC 7591 3.2.1: metadata values this AS does not advertise are rejected ---

# token_endpoint_auth_method: metadata advertises 'none' only
{
    my $e = exception {
        engine->register_client({
            redirect_uris              => ['https://app.example/cb'],
            token_endpoint_auth_method => 'client_secret_basic',
        });
    };
    isa_ok( $e, 'Catalyst::Plugin::OAuth2::AuthorizationServer::Error',
        'client_secret_basic registration' );
    is( $e->error, 'invalid_client_metadata',
        'unadvertised token_endpoint_auth_method rejected' );

    my $ok = engine->register_client({
        redirect_uris              => ['https://app.example/cb'],
        token_endpoint_auth_method => 'none',
    });
    is( $ok->{token_endpoint_auth_method}, 'none',
        'the advertised token_endpoint_auth_method is accepted' );
}

# grant_types: metadata advertises authorization_code + refresh_token
{
    my $e = exception {
        engine->register_client({
            redirect_uris => ['https://app.example/cb'],
            grant_types   => [ 'authorization_code', 'client_credentials' ],
        });
    };

t/server-dcr.t  view on Meta::CPAN

    is( $ok->{logo_uri},    'https://app.example/logo.png', 'logo_uri kept' );
    is_deeply( $ok->{contacts}, ['dev@app.example'],      'contacts kept' );
    is( $ok->{software_id}, 'abc-123', 'unknown extension field not rejected' );
}

# a fully-specified valid registration round-trips through the store
{
    my $eng = engine( scopes_supported => ['example:read'] );
    my $client = $eng->register_client({
        redirect_uris              => ['https://app.example/cb'],
        token_endpoint_auth_method => 'none',
        grant_types                => [ 'authorization_code', 'refresh_token' ],
        response_types             => ['code'],
        scope                      => 'example:read',
        client_name                => 'Round Trip',
    });
    like( $client->{client_id}, qr/\A[A-Za-z0-9_-]+\z/, 'client_id minted' );
    is_deeply( $eng->store->find_client( $client->{client_id} ), $client,
        'full valid registration round-trips through the store' );
}

t/server-metadata.t  view on Meta::CPAN

use v5.36;
use Test::More;
use lib 't/lib';
use StubStore;

my $class = 'Catalyst::Plugin::OAuth2::AuthorizationServer::Server';
require_ok($class);

# defaults derive endpoints from issuer
{
    my $eng = $class->new(
        store => StubStore->new, signing_key => 'k' x 32,
        issuer => 'https://as.example', resource => 'https://rs/mcp',
    );
    my $m = $eng->metadata_document;
    is( $m->{issuer}, 'https://as.example', 'issuer' );
    is( $m->{authorization_endpoint}, 'https://as.example/authorize', 'authorize ep' );
    is( $m->{token_endpoint},         'https://as.example/token',     'token ep' );
    is( $m->{registration_endpoint},  'https://as.example/register',  'register ep' );
    is_deeply( $m->{response_types_supported}, ['code'], 'response_types' );
    is_deeply( $m->{grant_types_supported},
        [ 'authorization_code', 'refresh_token' ], 'grant_types' );
    is_deeply( $m->{code_challenge_methods_supported}, ['S256'], 'S256 only' );
    is_deeply( $m->{token_endpoint_auth_methods_supported}, ['none'],
        'public client' );
    ok( !exists $m->{scopes_supported}, 'no scopes key when unconfigured' );
}

# explicit endpoints + scopes
{
    my $eng = $class->new(
        store => StubStore->new, signing_key => 'k' x 32,
        issuer => 'https://as', resource => 'https://rs/mcp',
        authorize_endpoint    => 'https://as/oauth/authorize',
        token_endpoint        => 'https://as/oauth/token',
        registration_endpoint => 'https://as/oauth/register',
        scopes_supported      => [ 'example:read', 'example:themes:write' ],
    );
    my $m = $eng->metadata_document;
    is( $m->{authorization_endpoint}, 'https://as/oauth/authorize', 'explicit authorize ep' );
    is_deeply( $m->{scopes_supported},
        [ 'example:read', 'example:themes:write' ], 'scopes advertised' );
}

done_testing;



( run in 1.267 second using v1.01-cache-2.11-cpan-92ad3014f07 )