Catalyst-Plugin-OAuth2-AuthorizationServer
view release on metacpan or search on metacpan
t/catalyst-app.t view on Meta::CPAN
use v5.36;
use Test::More;
use lib 't/lib';
use HTTP::Request::Common qw/GET POST/;
use JSON::MaybeXS qw/encode_json decode_json/;
use Digest::SHA qw/sha256/;
use MIME::Base64 qw/encode_base64url/;
use Crypt::JWT qw/decode_jwt/;
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};
ok( $client_id, 'got client_id' );
}
# --- DCR rate-limit gate denies when the app says so ---
{
my $res = request( POST '/oauth/register',
'Content-Type' => 'application/json',
'X-Deny-DCR' => 1,
Content => encode_json({ redirect_uris => ['https://app/cb'] }) );
is( $res->code, 429, 'rate-limit gate -> 429' );
is( decode_json( $res->content )->{error}, 'too_many_requests', 'error body' );
}
# --- authorize: our TestApp authn hook auto-consents as "user-1" and
# redirects to redirect_uri?code=... ---
my $verifier = 'the-code-verifier-value-0123456789012345678';
my $challenge = encode_base64url( sha256($verifier) );
my $code;
{
my $res = request( GET '/oauth/authorize?'
. "client_id=$client_id&redirect_uri=https://app/cb&response_type=code"
. "&code_challenge=$challenge&code_challenge_method=S256"
. '&scope=example:read&resource=https://rs/mcp&state=st1' );
is( $res->code, 302, 'authorize redirects' );
my $loc = $res->header('Location');
like( $loc, qr{\Qhttps://app/cb\E\?code=}, 'redirect carries code' );
like( $loc, qr/state=st1/, 'redirect carries state' );
($code) = $loc =~ /code=([^&]+)/;
ok( $code, 'extracted code' );
}
# --- token: authorization_code grant ---
my $refresh;
{
my $res = request( POST '/oauth/token', [
grant_type => 'authorization_code',
code => $code,
redirect_uri => 'https://app/cb',
code_verifier => $verifier,
] );
is( $res->code, 200, 'token 200' );
is( $res->header('Cache-Control'), 'no-store', 'no-store on token response' );
my $tok = decode_json( $res->content );
is( $tok->{token_type}, 'Bearer', 'Bearer' );
my $claims = decode_jwt( token => $tok->{access_token}, key => $KEY );
is( $claims->{sub}, 'user-1', 'access token sub' );
is( $claims->{aud}, 'https://rs/mcp', 'aud' );
$refresh = $tok->{refresh_token};
ok( $refresh, 'refresh token present' );
}
t/catalyst-app.t view on Meta::CPAN
my $loc = $res->header('Location');
like( $loc, qr{\Qhttps://app/cb\E\?}, 'redirects to the client redirect_uri' );
like( $loc, qr/error=invalid_scope/, 'carries error=invalid_scope' );
like( $loc, qr/state=st2/, 'carries state' );
}
# --- authorize error with an UNKNOWN client renders directly (no open redirect) ---
{
my $res = request( GET '/oauth/authorize?'
. 'client_id=ghost&redirect_uri=https://evil/cb&response_type=code'
. "&code_challenge=$challenge&code_challenge_method=S256"
. '&scope=example:read&resource=https://rs/mcp' );
is( $res->code, 400, 'unknown client renders directly, never redirects' );
is( decode_json( $res->content )->{error}, 'invalid_client',
'invalid_client body' );
}
# oauth_issue_code with a bogus request_id renders a clean 400, not a 500
{
my $res = request( GET '/test/issue-code?request_id=does-not-exist' );
is( $res->code, 400, 'expired/unknown request_id -> 400 (not 500)' );
is( decode_json( $res->content )->{error}, 'invalid_request',
'invalid_request envelope, no backtrace leak' );
}
# missing grant_type -> invalid_request (not unsupported_grant_type)
{
my $res = request( POST '/oauth/token', [ code => 'x' ] );
is( $res->code, 400, 'missing grant_type -> 400' );
is( decode_json( $res->content )->{error}, 'invalid_request',
'missing grant_type is invalid_request' );
}
# --- duplicate parameters are rejected, not silently collapsed (RFC 6749 3.2.1) ---
{
my $res = request( GET '/oauth/authorize?'
. "client_id=$client_id&client_id=someone-else"
. '&redirect_uri=https://app/cb&response_type=code'
. "&code_challenge=$challenge&code_challenge_method=S256"
. '&resource=https://rs/mcp&state=dup1' );
is( $res->code, 400, 'duplicated client_id on authorize -> 400' );
is( decode_json( $res->content )->{error}, 'invalid_request',
'duplicated authorize param is invalid_request' );
}
# a duplicated redirect_uri must render directly: rejecting it happens before
# any validation, so it must never 302 to a client-supplied URI (open redirect)
{
my $res = request( GET '/oauth/authorize?'
. "client_id=$client_id&redirect_uri=https://app/cb"
. '&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' );
}
done_testing;
( run in 1.048 second using v1.01-cache-2.11-cpan-92ad3014f07 )