NAME

Catalyst::Plugin::OAuth2::ResourceServer - MCP-profile OAuth 2.1 Resource Server plugin for Catalyst

SYNOPSIS

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

__PACKAGE__->config(
    'Catalyst::Plugin::OAuth2::ResourceServer' => {
        signing_key           => $ENV{MCP_OAUTH_JWT_KEY},  # same key the AS mints with
        resource              => 'https://api.example/mcp',
        issuer                => 'https://api.example',
        authorization_servers => ['https://api.example'],
        scopes_supported      => ['example:read'],
    },
);

# re-validate the subject on every request; return undef to force a 401
sub oauth_resolve_subject ( $c, $claims ) {
    my $user = MyApp::User->active->find( $claims->{sub} ) or return undef;
    return $user;
}

__PACKAGE__->setup;

# in a controller
sub mcp :Path('/mcp') :Args(0) {
    my ( $self, $c ) = @_;
    return unless $c->oauth_protect;
    return unless $c->oauth_assert_scope('example:read');
    # $c->oauth_identity / $c->oauth_claims now available
}

Mount oauth_protected_resource_metadata at /.well-known/oauth-protected-resource.

CONFIGURATION

Configuration is supplied under the key Catalyst::Plugin::OAuth2::ResourceServer in the Catalyst app config. The seam reads keys by name, so a typo'd key is silently ignored (a missing required engine key such as signing_key still croaks at construction, but a typo'd authorization_servers just yields an empty list in the metadata document).

signing_key (required)

The symmetric key (plain string for HS256/HS384/HS512) used to verify bearer JWTs. Must match the key the Authorization Server mints with.

resource (required)

The resource identifier (URI string, or arrayref of URIs) used to verify the aud claim. The first element is advertised as the canonical resource id in the metadata document.

issuer (required)

The expected iss claim value; tokens from other issuers are rejected.

jwt_alg

The permitted JWT algorithm. Defaults to HS256; supported values are HS256, HS384, HS512.

leeway

Seconds of clock-skew leeway for exp, nbf and iat verification. Default 0.

authorization_servers

Arrayref of Authorization Server URIs. Included in the RFC 9728 metadata document; not used for token verification. A typo here yields an empty list in the metadata: it is not caught at construction.

scopes_supported

Optional arrayref of scope strings to advertise in the metadata document.

resource_metadata_url

Override for the resource_metadata URI embedded in 401 challenges. When set, it is used verbatim and always wins over the derivation below.

When it is not set, the URI is derived from the first resource value per RFC 9728 section 3.1: the /.well-known/oauth-protected-resource segment is inserted after the authority and before any path of the resource. So https://api.example.com/v1 derives https://api.example.com/.well-known/oauth-protected-resource/v1, and https://api.example.com derives https://api.example.com/.well-known/oauth-protected-resource.

Derivation only works for an http or https resource identifier. A URN (urn:example:resource) or any other non-hierarchical scheme has no authority to insert the well-known segment after, so nothing can be derived: the resource_metadata parameter is then omitted from the challenge (which remains a valid RFC 6750 Bearer challenge; resource_metadata is optional) and a warning is logged once per process. If your resource is not an http(s) URI and you want 401 challenges to advertise your metadata document, you must set resource_metadata_url explicitly.

DESCRIPTION

Protects Catalyst routes with OAuth 2.1 bearer tokens: verifies the access-token JWT, calls an app-supplied subject resolver, exposes claims/scopes, and emits RFC 6750 challenges + an RFC 9728 protected-resource metadata document. The verification engine lives in Catalyst::Plugin::OAuth2::ResourceServer::Server; this module is the thin Catalyst seam.

Challenge and metadata responses are sent with Cache-Control: no-store. The consuming application is responsible for setting an appropriate Cache-Control on its own protected responses (RFC 6750 section 5.3).

METHODS

oauth_protect

return unless $c->oauth_protect;

Extracts a Bearer token from the Authorization header (scheme name is case-insensitive per RFC 7235), verifies it via the engine, and optionally calls the app hook oauth_resolve_subject. Returns true on success and stashes identity + claims; writes a 401 challenge and returns false on failure.

oauth_assert_scope( @required )

return unless $c->oauth_assert_scope('example:read', 'example:write');

Returns true if all required scopes are present in the verified token; writes a 403 insufficient_scope challenge and returns false otherwise. Call after oauth_protect.

oauth_claims

Returns the raw decoded JWT claims hashref (after a successful oauth_protect), or undef.

oauth_scopes

Returns the list of scopes from the token's scope claim (split on whitespace), or an empty list.

oauth_identity

Returns the identity value returned by oauth_resolve_subject (if the hook was defined), or undef.

oauth_protected_resource_metadata

Emits the RFC 9728 protected-resource metadata JSON document with Cache-Control: no-store. Mount at /.well-known/oauth-protected-resource.

oauth_challenge( $err )

$c->oauth_challenge;                       # plain 401
$c->oauth_challenge( $err_obj );           # 401/403 with error= params

Writes a WWW-Authenticate: Bearer response. $err is an optional Catalyst::Plugin::OAuth2::ResourceServer::Error object. 401 responses include a resource_metadata parameter whenever one is available: either configured via resource_metadata_url or derivable from an http(s) resource (see "CONFIGURATION"). All parameter values are escaped as RFC 7230 quoted-strings.

APP HOOKS

oauth_resolve_subject( $c, $claims )

Optional method the consuming application may define. Receives the Catalyst context and the decoded JWT claims hashref; should return an identity value (any truthy scalar or hashref) to allow the request, or undef to reject it with a generic 401 invalid_token challenge.

Security note: if you do not provide oauth_resolve_subject, oauth_protect accepts any cryptographically valid, non-expired token without checking whether the subject still exists or is active. Token revocation and account-deactivation enforcement depend entirely on this hook; omitting it means a revoked subject's token keeps working until it expires. Also: oauth_identity is undef when no resolver is defined, so guard before dereferencing it.

EXAMPLES

A runnable example lives in examples/: a small Catalyst app protecting /api/whoami with a Bearer token, plus a core-Perl client that mints its own demo JWT (via Crypt::JWT) to exercise it. 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.