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
audclaim. The first element is advertised as the canonical resource id in the metadata document. issuer(required)-
The expected
issclaim value; tokens from other issuers are rejected. jwt_alg-
The permitted JWT algorithm. Defaults to
HS256; supported values areHS256,HS384,HS512. leeway-
Seconds of clock-skew leeway for
exp,nbfandiatverification. Default 0. -
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_metadataURI 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
resourcevalue per RFC 9728 section 3.1: the/.well-known/oauth-protected-resourcesegment is inserted after the authority and before any path of the resource. Sohttps://api.example.com/v1deriveshttps://api.example.com/.well-known/oauth-protected-resource/v1, andhttps://api.example.comderiveshttps://api.example.com/.well-known/oauth-protected-resource.Derivation only works for an
httporhttpsresource 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: theresource_metadataparameter is then omitted from the challenge (which remains a valid RFC 6750Bearerchallenge;resource_metadatais optional) and a warning is logged once per process. If yourresourceis not an http(s) URI and you want 401 challenges to advertise your metadata document, you must setresource_metadata_urlexplicitly.
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
Jesse Vincent for
/superpowers(https://github.com/obra/superpowers) and theAGENTS.mdboilerplateCurtis "Ovid" Poe for
/paad(https://github.com/Ovid/paad)
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.