NAME

Punk::Plugin::OAuth2 - social login for Punk applications

VERSION

Version 0.05

SYNOPSIS

package MyApp;
use Punk;
use Punk::Plugin::OAuth2;

plugin 'OAuth2';

session secret => secret('session_key'), expires => '7d';

oauth2 google => {
	preset        => 'google',
	client_id     => secret('oauth.google_id'),
	client_secret => secret('oauth.google_secret'),
};
oauth2 github => {
	preset        => 'github',
	client_id     => secret('oauth.github_id'),
	client_secret => secret('oauth.github_secret'),
};
oauth2 corp => {
	issuer        => 'https://idp.corp.example',
	discovery     => 1,
	client_id     => '...',
	client_secret => '...',
};

oauth2_login '/auth' => {
	on_login => 'Auth#on_login',
	base_url => 'https://app.example.com',
};

package MyApp::Controller::Auth;

sub on_login {
	my ($c, $identity, $tokens) = @_;
	$c->session->{user} = { id => find_user($identity)->id };
	return;   # falls through to the post-login redirect
}

DESCRIPTION

OAuth2 and OpenID Connect for Punk. The plugin installs three keywords covering both sides of the protocol:

  • oauth2 + oauth2_login - the client (social login): authorization-code flow with PKCE (S256 only), single-use signed state, OIDC nonce, id_token verification through Crypt::JWS and a cached JWKS, and normalized identities across providers.

  • oauth2_server - a full authorization server (see Punk::OAuth2::Server).

For the resource-server side (validating incoming Bearer tokens as route guards or in an OpenAPI security map) see Punk::OAuth2::Checker.

Declaring a provider records configuration; plugin 'OAuth2' builds providers and mounts routes - either ordering of keyword and plugin works, and mismatches croak at to_app.

oauth2_login '/auth' mounts GET /auth/:provider (starts a login) and GET /auth/:provider/callback (completes it). Flow state lives in the signed session - at most three concurrent login attempts, each valid for ten minutes, deleted on use. Tokens are handed to on_login and then discarded: nothing token-shaped is ever stored in the session.

KEYWORDS

oauth2 NAME => \%config

Declares a provider. preset is google, github, or oidc; every preset field can be overridden. Without a preset, set issuer plus discovery => 1 (endpoints from the OIDC discovery document, issuer-checked and SSRF-guarded) or explicit authorization_endpoint/token_endpoint. Other options:

client_id / client_secret

The registration with the provider. client_secret may be omitted for a public client (public => 1) - PKCE still applies.

scope

Space-separated scope string; presets carry sensible defaults.

auth_method

basic (default) or body - how client credentials reach the token endpoint.

algs

id_token signature allowlist, default ['RS256', 'ES256'].

allow_local

Relax the SSRF guard for loopback IdPs (development and tests).

ua

A Fetch-compatible agent or a coderef returning one - the test seam; defaults to $c->ua.

oauth2_login PATH => \%options

Mounts the login routes. Options:

on_login (required)

Coderef or 'Controller#method', called as ($c, $identity, $tokens) after a verified login. $identity is { provider, sub, email, email_verified, name, picture, raw }; $tokens is a Punk::OAuth2::Tokens. A returned reference becomes the response; otherwise the user is redirected to the flow's return destination or redirect_ok.

base_url

The application's external origin, used to build exact redirect URIs. Required in production; trust_proxy => 1 derives it from X-Forwarded-Proto/Host instead when running behind a trusted proxy.

redirect_ok

Default post-login destination (/). A ?return= parameter on the initiation URL overrides it when it is a same-origin relative path - absolute and protocol-relative destinations are ignored.

on_error

Optional handler for failed logins; the default is a plain 400 page. Details go to the log, never the browser.

oauth2_server PATH => \%options

Mounts an OAuth2 / OpenID Connect authorization server at PATH: the authorize, token, revoke, introspect, and jwks.json endpoints, plus the RFC 8414 metadata at /.well-known/oauth-authorization-server and /.well-known/openid-configuration (registered at the app root). The token, revoke, and introspect POSTs are client-authenticated and are mounted CSRF-exempt.

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

Grants: authorization code with PKCE (S256 only), refresh_token (with rotation and family revocation on reuse), and client_credentials. Access tokens are ES256 JWTs. See Punk::OAuth2::Server for the full behaviour. Options:

issuer (required)

The issuer URL; it appears in tokens and metadata and must match what resource servers expect.

store (required)

A Punk::OAuth2::Server::Store, a { dsn = ... }> hashref (built into one for you), or any object satisfying the store contract - see "CREATING YOUR OWN STORE" in Punk::OAuth2::Server::Store.

authenticate (required)

Coderef or 'Controller#method', called with the context; returns the authenticated user id, or a reference (a login redirect) that short-circuits the authorization request.

sub require_user {
	my ($c) = @_;
	return $c->session->{user_id} if $c->session->{user_id};
	return $c->redirect('/login?to=' . ...);   # a reference wins
}

Optional. Called as ($c, $client, \@scopes); returns 1 (approve), 0 (deny), or a reference (render a consent page). Approvals are persisted so returning users skip consent.

alg, at_ttl, rt_ttl, key

Signing algorithm (default ES256), access- and refresh-token lifetimes (seconds), and an explicit signing key (a Crypt::JWS::Key; one is generated otherwise).

oauth2_server '/oauth' => {
	issuer => ..., store => ...,
	authenticate => ...,
	alg => 'ES256', at_ttl => 600, rt_ttl => 30 * 86400,
};

Both keyword and plugin orderings work, and inline declaration via plugin 'OAuth2' => { server => {...} } is equivalent to the keyword.

CONTEXT HELPERS

The plugin installs these on the context for use inside handlers.

oauth2_authurl

get '/signin' => sub {
	my ($c) = @_;
	my $url = $c->oauth2_authurl('google', return_to => '/dashboard');
	return $c->render(template => 'signin', google_url => $url);
};

Builds the authorization URL for a provider (for hand-rolled sign-in buttons) and records the login flow in the session, so its callback can complete just like oauth2_login's own route. return_to is an optional post-login destination.

oauth2_refresh

my $fresh = $c->oauth2_refresh('google', $tokens);
# or with a raw refresh-token string:
my $fresh = $c->oauth2_refresh('google', $refresh_token);

Performs a refresh-token grant against the provider and returns a fresh Punk::OAuth2::Tokens.

oauth2_provider

my $provider = $c->oauth2_provider('google');
my $url      = $provider->authorize_url(...);

Returns the configured Punk::OAuth2::Provider object for advanced use.

SECURITY NOTES

PKCE is always on and S256 only. State is 256 random bits, single-use, ten-minute lifetime. The OIDC nonce is checked against the id_token in constant time. id_token verification enforces the algorithm allowlist, issuer equality, audience (and azp with multiple audiences), and expiry with 60 seconds of leeway. Discovery and JWKS URLs must be https and resolve to public addresses unless allow_local is set. The ?return= destination is constrained to same-origin relative paths. Provider tokens are never written to the session.

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)