NAME

Catalyst::Plugin::OAuth2::AuthorizationServer - MCP-profile OAuth 2.1 Authorization Server plugin for Catalyst

SYNOPSIS

package MyApp;
use Catalyst qw/+Catalyst::Plugin::OAuth2::AuthorizationServer/;

__PACKAGE__->config(
    'Catalyst::Plugin::OAuth2::AuthorizationServer' => {
        store       => 'OAuth::Store',           # $c->model name, or an object
        signing_key => $ENV{MCP_OAUTH_JWT_KEY},
        issuer      => 'https://api.example',
        resource    => 'https://api.example/mcp',
        scopes_supported => [ 'example:read' ],
    },
);

# the app provides the authn/consent hook; the plugin calls it after it has
# validated /authorize and stashed the request under an opaque request_id
sub oauth_authenticate ( $c, $request_id ) {
    # 302 to your SPA consent route carrying $request_id; after consent the
    # SPA posts back and you call $c->oauth_issue_code($user_id, $request_id)
}

__PACKAGE__->setup;

In a controller, mount the routes onto the plugin methods: oauth_metadata, oauth_register, oauth_authorize, oauth_token.

DESCRIPTION

Adds an OAuth 2.1 Authorization Server (the MCP profile: public PKCE-S256 client, authorization_code + refresh_token grants, Dynamic Client Registration, and AS metadata) to a Catalyst application. The protocol engine lives in Catalyst::Plugin::OAuth2::AuthorizationServer::Server; this module is the thin Catalyst seam.

Access tokens are signed with a symmetric HMAC algorithm only: jwt_alg may be HS256 (the default), HS384 or HS512, and signing_key must be at least 32, 48 or 64 bytes respectively (RFC 7518 3.2). Asymmetric signing and alg=none are not supported and no JWKS is published, which is deliberate for the MCP single-server profile: the Authorization Server and the Resource Server share a deployment and a key. If you need an AS whose tokens are verified by a third party you do not share a secret with, this is not that plugin.

Repeated request parameters are rejected with invalid_request rather than collapsed to a single value (RFC 6749 3.2.1).

METHODS

oauth_metadata

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

oauth_register

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

oauth_authorize

Validates the authorize query parameters via the engine. On success, calls the app hook oauth_authenticate($c, $request_id) (see below). On a redirect-safe error (valid client + redirect_uri already confirmed), redirects to the redirect_uri with error= and state= params (RFC 6749 4.1.2.1). Otherwise renders a JSON error envelope directly.

oauth_issue_code( $subject, $request_id )

Called BY the app (typically inside oauth_authenticate) once the user has consented. Returns { code, redirect_uri, state } on success, or undef if the request_id is unknown/expired (in which case the OAuth error envelope has already been rendered). Callers must check the return value: my $out = $c->oauth_issue_code(...); return unless $out;

oauth_token

Reads form parameters, dispatches authorization_code or refresh_token grants via the engine, writes 200 JSON with Cache-Control: no-store.

oauth_error( $error, $status, $desc )

Render a bare OAuth error envelope. $status defaults to 400; $desc is optional.

APP HOOKS

The consuming Catalyst application must provide:

oauth_authenticate( $c, $request_id )

Called by oauth_authorize after the request is validated. A real app would redirect to a login/consent page. In that page handler, call $c->oauth_issue_code($subject, $request_id) and redirect to the returned redirect_uri carrying the code (and state).

oauth_dcr_allow_registration( $c ) (optional)

If present and returns false, oauth_register responds 429 too_many_requests. Use for rate-limiting DCR.

LIMITATIONS

Refresh-token reuse revokes the whole token family (RFC 9700): when a rotated token is replayed, every refresh token descended from the same authorization is revoked, including the one the legitimate client currently holds. Reuse detection depends on the Store retaining rotated tokens until they expire; see rotate_refresh_token in Catalyst::Plugin::OAuth2::AuthorizationServer::Role::Store.

Security limitation: revoking the family does not kill access tokens already minted from it. They are stateless JWTs, verified without consulting the Store, and stay valid until access_ttl elapses. Keep access_ttl short. Access tokens carry a 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 invalid_grant rather than surviving with a live token. The client must start a new authorization.

Apps can call 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.

EXAMPLES

A runnable example lives in 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 plackup examples/app.psgi and run perl examples/client.pl. See examples/README.md.

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).

With thanks to

for providing an agentic development framework that keeps code authority firmly where it belongs.

Iteratively reviewed by Finn Kempers <finn@shadow.cat> with analysis from ZCode/GLM-5.2.

LICENSE

This library is free software; you can redistribute it and/or modify it under the terms of the Artistic License, as distributed with Perl.