NAME

Punk::OAuth2::Provider - one configured OAuth2/OIDC provider

SYNOPSIS

# the usual route: the oauth2 keyword builds one of these for you
oauth2 google => { preset => 'google', client_id => '...' };

# built directly, and driven by hand
my $p = Punk::OAuth2::Provider->new(google =>
	preset        => 'google',
	client_id     => $id,
	client_secret => $secret,
);

$p->ensure_endpoints($c);

my $url = $p->authorize_url(
	redirect_uri => 'https://app.example.com/auth/google/callback',
	state        => $state,
	nonce        => $nonce,
	challenge    => $challenge,
);

my $tokens   = $p->exchange($c, code => $code,
                                redirect_uri => $redirect_uri,
                                verifier     => $verifier,
                                nonce        => $nonce);
my $identity = $p->identity($c, $tokens);

DESCRIPTION

A provider is one entry in your OAuth2 configuration made executable: where to send the user, how to trade the code for tokens, and how to turn the answer into an identity you can trust. It is a blessed hashref of the merged configuration, and every method is implemented in XS.

Normally you never construct one. Punk::Plugin::OAuth2 builds a provider per oauth2 declaration and drives it from the login routes, which is where the session state, PKCE and redirect handling live. The class is documented because it is also usable on its own - for a flow the plugin does not mount, a background token refresh, or a test - and because its behaviour is what the plugin's security guarantees rest on.

The security-relevant behaviour is in "ENDPOINT DISCOVERY", "THE SSRF GUARD" and "ID TOKEN VERIFICATION".

CONSTRUCTOR

new

my $p = Punk::OAuth2::Provider->new($name, %config);

$name is the local name for this provider, the one that appears in route paths and in the provider field of an identity. The rest is a flat list of configuration pairs, not a hashref; an odd number of them croaks.

If %config contains preset, that preset is laid down first and the rest of %config is merged over it key by key. See Punk::OAuth2::Presets.

Recognised keys, beyond whatever a preset supplied:

client_id

Required. Everything else has either a default or a preset behind it; this does not.

client_secret

Omit it only for a public client, which must then say so with public => 1. An OIDC provider with neither croaks at construction rather than failing later at the token endpoint.

issuer

The provider's issuer identifier. Required when discovery is on, where it is both the base for the discovery URL and the value the document must agree with. Also the value iss is checked against in every id_token.

discovery

Fetch the endpoints from the issuer's discovery document instead of configuring them. Default off. See "ENDPOINT DISCOVERY".

authorization_endpoint / token_endpoint

Required unless discovery is on. Anything else configured explicitly is kept as-is and never overwritten by discovery.

jwks_uri

Where the signing keys live. Without it there is no key set, and an OIDC login can only verify an id_token if the algs allowlist permits HMAC (see "ID TOKEN VERIFICATION").

userinfo_endpoint

Recorded from configuration or discovery. Identity is taken from the id_token, so this is not called during a normal login.

identity_endpoint / emails_endpoint / identity_map

The plain-OAuth2 path: where to fetch the user, optionally where to fetch their addresses, and how to normalize the result. Documented under "IDENTITY MAPPING" in Punk::OAuth2::Presets.

oidc

Whether this provider issues id_tokens. Presets set it; set it yourself for a hand-configured provider.

scope

Space-separated scope string. Defaults to empty, in which case no scope parameter is sent at all.

auth_method

How client credentials reach the token endpoint: basic (the default, HTTP Basic) or body (form fields).

algs

Signature algorithms accepted on an id_token. Defaults to ['RS256', 'ES256']. This is an allowlist, and it is the only thing that enables HMAC verification.

leeway

Clock skew allowed on exp and iat, in seconds. Default 60.

authorize_params

Extra static query parameters merged into every authorization URL, as a hashref. This is where provider-specific extras go: prompt, access_type, hd and so on.

token_headers

Extra headers sent with the token request, as a hashref.

allow_local

Relax the SSRF guard for a loopback identity provider. For development and tests. See "THE SSRF GUARD".

public

Declares a client with no secret. PKCE still applies; this only suppresses the missing-secret check.

ua

The HTTP agent, or a coderef returning one. See "THE USER AGENT".

Construction validates rather than deferring: a missing client_id, an unsafe or absent issuer under discovery, missing endpoints without discovery, or an OIDC provider with no secret and no public flag all croak here, at declaration time, rather than on the first user who tries to log in.

ACCESSORS

name

The local name this provider was declared under.

issuer

The configured issuer, or undef.

scope

The scope string, empty rather than undef when none was set.

oidc

True if this provider issues id_tokens.

METHODS

Every method that talks to the network takes the Punk context $c as its first argument, and uses it to find an agent (see "THE USER AGENT"). Under Hyperman these calls park the worker rather than blocking it.

ensure_endpoints

$p->ensure_endpoints($c);

Makes sure authorization_endpoint and token_endpoint are known, running discovery if they are not. Returns nothing, croaks on failure, and does nothing at all when both are already set - so it is cheap to call before every use, which is what the login routes do.

jwks

my $jwks = $p->jwks;

The Punk::OAuth2::JWKS key set for jwks_uri, or undef if there is no jwks_uri. Built once and cached on the provider, so the key set's own caching and negative caching apply across every login.

authorize_url

my $url = $p->authorize_url(
	redirect_uri => $uri,
	state        => $state,
	nonce        => $nonce,
	challenge    => $challenge,
);

The URL to send the user to. Always carries response_type=code and client_id; adds scope when non-empty, state and redirect_uri when given, and nonce when given and this is an OIDC provider. A challenge becomes code_challenge plus code_challenge_method=S256 - S256 is the only method offered, so there is no downgrade to plain to negotiate.

Any authorize_params are merged in, and can override the parameters above. Parameters are sorted by name and percent-encoded, and the separator is chosen by looking for an existing query string, so an endpoint that already carries one still produces a valid URL.

exchange

my $tokens = $p->exchange($c, code         => $code,
                              redirect_uri => $uri,
                              verifier     => $verifier,
                              nonce        => $nonce);

Trades an authorization code for tokens, returning a Punk::OAuth2::Tokens. verifier is the PKCE code verifier and nonce the value issued in the authorization request.

For an OIDC provider this also verifies the returned id_token and stores the verified claims on the tokens as id_claims. Verification failure croaks: a login that cannot prove who it is for does not proceed with an unverified one.

Croaks if the token endpoint answers non-200 or returns no access_token, quoting the provider's own error field when it sent one.

refresh

my $tokens = $p->refresh($c, $refresh_token);
my $tokens = $p->refresh($c, $old_tokens);

Runs the refresh_token grant. Accepts either the token string or a Punk::OAuth2::Tokens to take it from, and croaks if there is nothing to refresh. Returns a fresh Tokens object.

Note that this does not verify an id_token even when the provider returns one; use "verify_id_token" if you need the new claims.

verify_id_token

my $claims = $p->verify_id_token($c, $id_token, nonce => $nonce);

Verifies an id_token and returns its claims as a hashref, or undef if anything about it fails to check out. Pass the nonce you issued so it can be compared. See "ID TOKEN VERIFICATION".

The undef return is deliberate: there is exactly one answer for every kind of failure, so nothing about the token can be inferred from how it was rejected.

identity

my $identity = $p->identity($c, $tokens);

The normalized identity behind a set of tokens:

{ provider, sub, email, email_verified, name, picture, raw }

For an OIDC provider this reads the verified id_claims left by "exchange" and croaks if they are absent, so an identity can never be built from an unverified token. email_verified is normalized to 1 or 0.

For a plain OAuth2 provider it calls identity_endpoint with the access token, plus emails_endpoint if configured, and passes both through identity_map. Without a map it falls back to reading sub (or id), email and name off the response.

raw is always the provider's untouched payload - the claims or the user response - so nothing normalization drops is actually lost.

ENDPOINT DISCOVERY

With discovery => 1, the endpoints come from $issuer/.well-known/openid-configuration, fetched once on the first "ensure_endpoints" and then kept on the provider.

The document is not taken at its word:

  • Its issuer must equal the configured issuer, compared exactly apart from a single trailing slash on either side. A document that names a different issuer is rejected rather than followed, which is what stops a compromised or misconfigured discovery URL from pointing a login at someone else's endpoints.

  • Every endpoint it offers is put through "THE SSRF GUARD" before it is stored, and an unsafe one croaks rather than being skipped.

  • Anything you configured explicitly wins. Discovery only fills in authorization_endpoint, token_endpoint, jwks_uri and userinfo_endpoint that are still missing, so pinning one endpoint by hand is always possible.

  • authorization_response_iss_parameter_supported is recorded as iss_param, so the callback can check the iss the provider echoes back (RFC 9207).

If the document arrives but still leaves the authorization or token endpoint unset, that croaks too.

THE SSRF GUARD

Discovery means fetching a URL a third party chose, so every URL that arrives that way is checked before it is used. By default:

  • https only.

  • No credentials in the URL.

  • No loopback host.

  • The host is resolved, and every address it resolves to is checked: if any is private, the URL is refused. Checking all of them, rather than just the first, is what makes DNS entries that mix a public and a private answer useless.

allow_local => 1 relaxes this to "https anywhere, or http to loopback", which is what a local identity provider in development or a test suite needs. It stays off by default because it is exactly the check that stops discovery from reaching into a private network.

ID TOKEN VERIFICATION

Two steps, both required.

First the signature. With a jwks_uri, the token's unverified header is peeked for its kid, the key is resolved from the key set, and Crypt::JWS verifies against it with algs as the allowlist. With no key set, the client_secret can serve as an HMAC key - but only if algs actually contains an HS* algorithm. Since the default is RS256 and ES256, a provider that ought to be using public keys cannot be talked into accepting a token signed with the client secret.

Then the claims, over the decoded payload:

  • iss must equal the configured issuer, up to a single trailing slash.

  • aud must contain client_id, whether it is a string or an array.

  • azp is required when there is more than one audience, and must be client_id.

  • exp is required, and checked against now with leeway.

  • iat, if present, must not be in the future by more than leeway.

  • nonce must match the one issued, compared in constant time. Only checked when a nonce was actually issued.

Any failure at any point yields undef, with no indication of which check it was.

THE USER AGENT

Requests go through, in order: the ua configuration if it is an object; ua called with $c if it is a coderef; $c->ua; and failing all of those, a Fetch agent with a 10 second timeout that the provider builds once and keeps.

Passing ua is the seam the test suite uses to run whole login flows against an in-process identity provider without a socket. A coderef is the form to use when the agent should come from the request rather than from the declaration.

SEE ALSO

Punk::Plugin::OAuth2 for the oauth2 declaration and the login routes that drive this class, Punk::OAuth2::Presets for the bundled provider configurations, Punk::OAuth2::Tokens for what exchange and refresh return, and Punk::OAuth2::JWKS for key fetching and caching.

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)