OAuth2 and OIDC

Punk::OAuth2 covers all three sides of the protocol: "Log in with ..." as a client, validating Bearer tokens as a resource server, and issuing them as a full authorization server. Signatures run through Crypt::JWS, a 100% XS libcrypto binding.

Social login

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 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',
};

oauth2_login '/auth' mounts GET /auth/:provider (starts a login) and GET /auth/:provider/callback (completes it), using the authorization-code flow with PKCE (S256 only), single-use signed state, an OIDC nonce, and id_token verification against a cached JWKS. Presets cover google, github and generic oidc; without one, discovery => 1 reads the issuer's discovery document (issuer-checked and SSRF-guarded).

on_login is called as ($c, $identity, $tokens) with a normalized identity - { provider, sub, email, email_verified, name, picture, raw } - and typically ends in $c->login($user). Tokens are handed over and then discarded: nothing token-shaped is ever stored in the session.

Resource server

Punk::OAuth2::Checker validates incoming Bearer tokens in XS - signature against a JWKS or static key, then issuer, audience and expiry - as a route guard or in an OpenAPI security map:

my $jwt = Punk::OAuth2::Checker->jwt(
    issuer   => 'https://idp.example.com',
    jwks_url => 'https://idp.example.com/oauth/jwks.json',
    audience => 'https://api.example.com',
);

under '/api' => Punk::OAuth2::Checker->guard($jwt, scopes => ['read:books']);
api 'openapi.json' => { security => { oauth => $jwt } };

Authorization server

oauth2_server '/oauth' => {
    issuer       => 'https://idp.example.com',
    store        => { dsn => 'dbi:SQLite:idp.db' },
    authenticate => sub {
        my ($c) = @_;
        return $c->auth_id // $c->redirect('/login?to=' . $c->req->path);
    },
};

One keyword serves /authorize, /token, /revoke, /introspect, /jwks.json and the RFC 8414 metadata. Grants are authorization code with PKCE, refresh_token with rotation, and client_credentials - the implicit and password grants are removed as in OAuth 2.1, and not configurable back on. Access tokens are RFC 9068 JWTs (ES256 by default), so a resource server validates them statelessly with the checker above; refresh tokens, codes and client secrets are opaque, and the store only ever holds their SHA-256 digests.