Catalyst-Plugin-OAuth2-AuthorizationServer
view release on metacpan or search on metacpan
lib/Catalyst/Plugin/OAuth2/AuthorizationServer/Server.pm view on Meta::CPAN
$self->_grant_error('unknown or revoked refresh token');
}
my $binding = $result->{binding};
# Mirror the code-exchange client binding check (RFC 6749 6).
if ( defined $params->{client_id} && length $params->{client_id} ) {
$self->_grant_error('client_id mismatch')
unless $params->{client_id} eq $binding->{client_id};
}
return $self->_issue_token_pair($binding);
}
sub _invalid_metadata ( $self, $desc ) {
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')
if @$uris > $self->redirect_uris_max;
for my $u (@$uris) {
$self->_invalid_metadata('redirect_uri not a string')
if ref $u || !defined $u || !length $u;
$self->_invalid_metadata('redirect_uri too long')
if length $u > $self->redirect_uri_max_length;
my $parsed = URI->new($u);
my $scheme = lc( $parsed->scheme // '' );
my $ok_scheme =
$scheme eq 'https' ? 1
: $scheme eq 'http'
&& $parsed->can('host')
&& ( $parsed->host // '' )
=~ m{\A(?:localhost|127\.\d+\.\d+\.\d+|::1)\z} ? 1
: 0;
$self->_invalid_metadata('redirect_uri scheme not allowed')
unless $ok_scheme;
$self->_invalid_metadata('redirect_uri must not contain a fragment')
if $parsed->can('fragment') && defined $parsed->fragment;
}
$self->_validate_client_metadata($metadata);
my $json = JSON::MaybeXS->new( utf8 => 1, canonical => 1 );
$self->_invalid_metadata('client metadata too large')
if length( $json->encode($metadata) ) > $self->metadata_max_bytes;
my $client = { %$metadata, client_id => $self->_random_token(16) };
return $self->store->create_client($client);
}
# 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"} };
for my $v (@$values) {
$self->_invalid_metadata("unsupported $field value")
if ref $v || !defined $v || !$ok{$v};
}
}
# scope is only constrained when the AS advertises scopes_supported.
if ( exists $metadata->{scope} && $self->scopes_supported ) {
my $scope = $metadata->{scope};
$self->_invalid_metadata('scope must be a string')
if ref $scope || !defined $scope;
my %ok = map { $_ => 1 } @{ $self->scopes_supported };
for my $s ( split ' ', $scope ) {
$self->_invalid_metadata(
'one or more requested scopes are not supported')
unless $ok{$s};
}
}
return;
}
=head1 NAME
Catalyst::Plugin::OAuth2::AuthorizationServer::Server - Pure-logic OAuth 2.1
Authorization Server engine
=head1 DESCRIPTION
The pure-logic OAuth 2.1 engine behind the Catalyst seam. Holds the Store
reference, the signing key, and configuration; is otherwise stateless (the
only mutable state lives in the injected Store).
Access tokens are signed with a symmetric HMAC algorithm only: C<jwt_alg> may
be C<HS256> (the default), C<HS384> or C<HS512>. Asymmetric signing (C<RS*>,
C<ES*>, C<PS*>) and C<alg=none> are not supported, and no JWKS is published:
this is deliberate for the MCP single-server profile, where the Authorization
Server and Resource Server share one deployment and one key. C<signing_key>
must be at least as long as the algorithm's hash output (32, 48 or 64 bytes
respectively, per RFC 7518 3.2); a shorter key is rejected at construction.
=head1 METHODS
=head2 mint_access_token( \%claims, $aud )
Mint a signed JWT access token. The engine stamps C<iss>, C<aud>, C<iat>,
C<exp> and C<jti>, and they are stamped after C<\%claims>, so a caller cannot
override them. C<$aud> defaults to the configured C<resource> list. Returns the
encoded JWT string.
=head2 register_client( \%metadata )
Dynamic Client Registration (RFC 7591). Validates C<redirect_uris> (must be
present; each must be HTTPS or loopback HTTP; no fragments; within length
limits). Generates a C<client_id>, calls C<Store::create_client>, and returns
the stored client hashref.
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
C<response_types_supported> (C<code>).
=item *
C<scope> is checked against C<scopes_supported> B<only> when the AS is
configured with one; with no C<scopes_supported> the server declares no
constraint, so C<scope> is left free-form.
=back
Everything else RFC 7591 leaves free-form (C<client_name>, C<client_uri>,
C<logo_uri>, C<contacts>, C<software_id>, extension fields) is stored as
given and never rejected merely for being present.
=head2 validate_authorize( \%params )
Validate an authorization request (RFC 6749 4.1.1 + PKCE RFC 7636). Checks
client, redirect_uri, response_type, code_challenge (must be a 43-character
base64url string), code_challenge_method (must be C<S256>), scope, and
resource. On success, stashes the request via
C<Store::save_authorization_request> and returns C<{ request_id }>.
=head2 issue_code( $subject, $request_id )
Atomically consume the stashed authorization request and mint a single-use
authorization code bound to C<$subject>. Returns C<{ code, redirect_uri,
state }>. Throws C<invalid_request> if the request is unknown or expired.
=head2 exchange_authorization_code( \%params )
Authorization-code grant (RFC 6749 4.1.3). Validates code, redirect_uri,
client_id binding, and PKCE verifier. Returns C<{ access_token, token_type,
expires_in, refresh_token }> (plus C<scope> if the request carried one).
=head2 refresh( \%params )
( run in 2.512 seconds using v1.01-cache-2.11-cpan-364913b4093 )