NAME

Punk::OAuth2::Server - an OAuth2/OIDC authorization server

SYNOPSIS

oauth2_server '/oauth' => {
	issuer  => 'https://idp.example.com',
	store   => { dsn => 'dbi:SQLite:idp.db' },
	authenticate => 'Auth#require_user',
	consent      => 'Auth#consent',
};

DESCRIPTION

The other half of the distribution. Where Punk::OAuth2::Provider logs your users in somewhere else, this issues the tokens: an OAuth2 authorization server with OpenID Connect flavouring, implemented in XS, mounted by the oauth2_server keyword.

It serves /authorize, /token, /revoke, /introspect, /jwks.json and the RFC 8414 metadata documents. You supply two hooks - how to authenticate a user and, optionally, how to ask them for consent - plus a store. Everything else, the whole protocol, is here.

Supported grants are authorization code with PKCE, refresh_token with rotation, and client_credentials. There is no implicit grant and no resource owner password grant, and they are not configurable off or on: both are removed in OAuth 2.1, and a server that cannot perform them cannot be talked into performing them.

Access tokens are JWTs (RFC 9068 at+jwt) signed with ES256 by default, so a resource server validates them statelessly with Punk::OAuth2::Checker and never calls back here. Refresh tokens, authorization codes and client secrets are opaque random strings, and the store only ever holds their SHA-256 digests.

CONSTRUCTOR

new

my $server = Punk::OAuth2::Server->new(%opts);

Usually called for you by oauth2_server. An odd number of options croaks.

issuer (required)

The issuer URL. It goes in the iss and aud of every access token and in the metadata, and it is what a resource server checks, so it has to be the URL clients actually reach.

store (required)

Where codes, refresh tokens, clients and consents live. A Punk::OAuth2::Server::Store, a { dsn => ... } hashref, or any object implementing the same methods. See "THE STORE".

authenticate

Coderef or 'Controller#method', called with the context. Returns the authenticated user id, or a reference - typically a redirect to your login page - which is returned to the browser as-is. Returning false denies the authorization. Required in practice: /authorize answers 500 without it.

Optional. Called as ($c, $client, \@scopes) with the client row and the requested scopes split on spaces. Return true to approve, false to deny, or a reference to render a consent page. An approval is recorded, so returning users are not asked again for that client.

key

The signing key, a Crypt::JWS::Key. One is generated for alg if you do not supply one, which is fine for a single process and wrong for anything that restarts or runs more than one worker - see "THE SIGNING KEY".

alg

Signing algorithm for access tokens. Default ES256.

at_ttl

Access token lifetime in seconds. Default 600.

rt_ttl

Refresh token lifetime in seconds. Default 2592000 (30 days).

prefix

Path the endpoints are mounted under, used to build the metadata URLs. Default /oauth.

oidc

Recorded on the server for OIDC behaviour.

ENDPOINTS

Each takes the Punk context and returns a PSGI triplet, so they can be mounted as routes directly, which is what oauth2_server does. Every JSON response carries Cache-Control: no-store and Pragma: no-cache.

authorize

$server->authorize($c);

The authorization endpoint. Reads its parameters from the query string: response_type, client_id, redirect_uri, state, scope, nonce, code_challenge and code_challenge_method.

What it does, in order, and the order is the point:

  1. Look up client_id, and match redirect_uri exactly against that client's registered set. Both are required. Failures here answer 400 text/plain and do not redirect - with an unverified redirect target there is nowhere safe to send an error, and redirecting anyway is how an authorization server becomes an open redirector.

  2. Require response_type=code and PKCE with code_challenge_method=S256. From here the redirect target is trusted, so these answer by redirecting back with error and state (unsupported_response_type and invalid_request respectively). S256 is the only accepted method: plain is not implemented, so it cannot be downgraded to.

  3. Check the request against the client's registration: the client must be registered for the authorization_code grant (unauthorized_client), and every scope in scope must be one of its registered scopes (invalid_scope). Both redirect back with the error. See "WHAT A CLIENT MAY ASK FOR".

  4. Call authenticate. A reference is returned to the browser unchanged, which is how you send the user to a login page. A false return redirects back with access_denied.

  5. Call consent, unless this user has already consented to this client. A reference renders your consent page; a false return is access_denied; a true return is recorded so it is not asked again.

  6. Mint a 32-byte random code bound to the client, user, redirect URI, scope, nonce and challenge, valid for ten minutes, and redirect back with code, state and iss.

The iss parameter on the response is RFC 9207, and lets a client detect a mix-up between two authorization servers.

token

$server->token($c);

The token endpoint. Parses an application/x-www-form-urlencoded body and authenticates the client (see "CLIENT AUTHENTICATION") before looking at the grant; an unauthenticated client is 401 invalid_client whatever it asked for.

The named grant must then be one the client is registered for, or it is 400 unauthorized_client before any arm runs - the grant type is a field in a body the client wrote, so it says what the client wants, not what it may have. See "WHAT A CLIENT MAY ASK FOR".

authorization_code

Consumes the code, which is single-use - the store deletes it on read, so a replay finds nothing. Then it checks that the code was issued to this client, has not expired, and was issued for exactly this redirect_uri, and that S256 of the supplied code_verifier equals the stored challenge, compared in constant time. Any failure is invalid_grant with no detail: a client that guessed wrong learns only that it was wrong.

Returns an access token and a refresh token.

refresh_token

Checks the token belongs to this client, is not revoked, and has not expired, then rotates it: a new refresh token is issued in the same family and the old one is marked as consumed.

If the presented token was already rotated, that is treated as theft rather than as a mistake - the legitimate client would be holding the new token - and the entire family is revoked, logging out the attacker and the victim together. The response is still a bare invalid_grant.

client_credentials

No user, no refresh token. The access token's sub is the client id.

Confidential clients only: a public client is 401 invalid_client here, because it authenticates on nothing but an identifier. The requested scope must be registered to the client, or the request is invalid_scope.

Anything else is unsupported_grant_type.

revoke

$server->revoke($c);

RFC 7009. Client-authenticated, and always answers 200 with an empty object - revoking an unknown or already-dead token is indistinguishable from revoking a live one, so the endpoint cannot be used to probe which tokens exist. Revoking a refresh token kills its whole rotation family.

introspect

$server->introspect($c);

RFC 7662. Client-authenticated. An access token is verified locally against this server's own key and algorithm; a refresh token is looked up in the store and must be unrevoked, unrotated and unexpired.

A live token answers with active true plus sub, scope, client_id and exp as available. Everything else - malformed, expired, revoked, never issued - answers with exactly {"active":false}, so nothing about a token can be learned from how it is refused.

jwks

$server->jwks($c);

The public half of the signing key as a JWKS document, with kid, use => 'sig' and alg set. This is what resource servers fetch to validate access tokens.

metadata

$server->metadata($c);

The RFC 8414 authorization server metadata: the issuer, the five endpoint URLs built from the issuer and prefix, and the supported grant types, response types, PKCE methods and signing algorithms. The plugin serves it at both /.well-known/oauth-authorization-server and /.well-known/openid-configuration.

prefix

The configured path prefix, defaulting to /oauth.

ACCESS TOKENS

Access tokens are signed JWTs with typ at+jwt (RFC 9068) and the kid of the signing key, carrying:

iss        the issuer
sub        the user id, or the client id for client_credentials
aud        the issuer
client_id  the client the token was issued to
exp / iat  from at_ttl
jti        a random 16-byte identifier
scope      when the grant carried one

Because they are verifiable offline, a resource server never talks to this one on the request path, and the cost of that is the usual one: a token stays valid until it expires. Keep at_ttl short - the default is ten minutes - and let the refresh token carry the long-lived authority, where it can be revoked.

CLIENT AUTHENTICATION

/token, /revoke and /introspect all authenticate the client first, by HTTP Basic (URL-decoded, per RFC 6749) or by client_id and client_secret in the form body. The presented secret is digested and compared against the stored digest in constant time.

A client registered with no secret is public, and authenticates on its client_id alone. That is not a weakening: a public client is one that cannot keep a secret, which is why the authorization code grant requires PKCE from everybody.

It does mean a public client is only ever as authenticated as an identifier anyone can read out of a browser, so the client_credentials grant - whose whole security is client authentication - is refused to one outright, with invalid_client (RFC 6749 section 4.4).

WHAT A CLIENT MAY ASK FOR

Two things come out of the request rather than the registration: the grant_type is a field in the token request body, and scope is a query parameter on the authorization request. Neither is the client's to choose. Both are checked against the client's own row:

  • The grant_type must be one of the client's registered grant_types, or the token endpoint answers 400 unauthorized_client. /authorize requires authorization_code the same way. Without this, a client registered for the authorization code flow alone could post grant_type=client_credentials and be handed a token of its own.

  • Every space-separated scope must be one of the client's registered scopes, or the request is invalid_scope - at /authorize, at /token for client_credentials, and again when a code or a refresh token is redeemed, so that narrowing a registration takes effect on credentials that were issued before it. A request that names no scope asks for nothing and is always allowed.

Both lists are deny by default, exactly as redirect_uris is: a client with no registered scopes gets no scope, and one with no registered grant_types can use no grant. Punk::OAuth2::Server::Store defaults grant_types to authorization_code refresh_token at registration when you do not say otherwise, so the list to think about in practice is scopes.

The point of the recheck at redemption is that an access token is signed and self-contained: once one is out, the only thing standing between a scope and a resource server is what was put in the token. So the registration is consulted every time one is minted, not once when the grant began.

THE SIGNING KEY

If you do not pass key, one is generated at construction. That is convenient for a test and wrong for a deployment: a generated key does not survive a restart, so every previously issued access token becomes unverifiable, and two workers would sign with two different keys while publishing one JWKS.

Generate a key once, keep it wherever your other secrets live, and pass it in:

oauth2_server '/oauth' => {
	issuer => 'https://idp.example.com',
	key    => Crypt::JWS::Key->from_pem(secret('idp.signing_key')),
	...
};

THE STORE

The server itself holds no state. Clients, authorization codes, refresh tokens and consents all live in the store, and every credential is digested before it gets there, so a copy of the database yields no usable token.

Punk::OAuth2::Server::Store is the shipped DBI implementation. Any object providing the same methods works: client_get, code_put, code_take, refresh_put, refresh_take, refresh_rotate, refresh_revoke_family, consent_get and consent_put. Its POD documents the contract, including which of them must delete and which must not.

CAVEATS

The authorization endpoint reads its parameters from the query string only. The plugin also mounts POST /authorize, but a form-encoded POST to it is currently answered with 400 missing client_id. Use GET.

SEE ALSO

Punk::Plugin::OAuth2 for the oauth2_server keyword that mounts this, Punk::OAuth2::Server::Store for the storage contract and schema, and Punk::OAuth2::Checker for the resource-server side that validates the tokens issued here.

AUTHOR

LNATION, <email at lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)