NAME
Punk::OAuth2::Server::Store - DBI storage for the authorization server
SYNOPSIS
use Punk::OAuth2::Server::Store;
# connect a fresh handle (tables auto-created)
my $store = Punk::OAuth2::Server::Store->new(
dsn => 'dbi:SQLite:idp.db', auto_migrate => 1);
# or wrap a handle you already have
my $store = Punk::OAuth2::Server::Store->new(dbh => $dbh);
# register a client (secret is stored as a digest, never in clear)
$store->client_put({
client_id => 'my-app',
secret => 'my-secret',
redirect_uris => ['https://my-app/callback'],
scopes => 'read write',
});
# hand it to the authorization server
oauth2_server '/oauth' => {
issuer => 'https://idp.example.com',
store => $store,
authenticate => 'Auth#require_user',
};
DESCRIPTION
The default storage backend for Punk::OAuth2::Server, implemented in XS over DBI. Every credential - client secret, authorization code, refresh token - is stored as a base64url SHA-256 digest, never in the clear. The tables are created for you unless you pass auto_migrate => 0.
The handle is fork-safe: when the store was opened from a dsn and the process id changes (a prefork worker), it reconnects automatically, so a handle is never shared across a fork.
CONSTRUCTOR
new
my $store = Punk::OAuth2::Server::Store->new(
dsn => 'dbi:SQLite:idp.db',
user => 'me', # optional
password => 'secret', # optional
auto_migrate => 1, # default; runs the schema
);
# or
my $store = Punk::OAuth2::Server::Store->new(dbh => $existing_dbh);
Pass either a dsn (with optional user/password) or an existing dbh. With a dsn the store remembers how to reconnect after a fork; a supplied dbh is used as-is (you own its lifecycle).
METHODS
The authorization server calls these; you rarely call them yourself except client_put (and the other client operations) for provisioning.
client_put
$store->client_put({
client_id => 'my-app',
secret => 'my-secret', # omit for a public client
name => 'My Application',
redirect_uris => ['https://my-app/callback'],
grant_types => 'authorization_code refresh_token',
scopes => 'read write',
auth_method => 'basic', # or 'body'
public => 0,
});
Inserts or replaces a client. secret is digested on the way in; a client with no secret is public (PKCE still applies).
grant_types and scopes are enforced, and are deny by default in the same way redirect_uris is: what is not listed cannot be asked for. A client with no scopes gets no scope, whatever it puts in the authorization request. Omitting grant_types registers authorization_code refresh_token, so client_credentials is something a client has to be given deliberately. Both are accepted as a space-separated string (what the column holds), a comma-separated one, an arrayref, or a JSON array. See "WHAT A CLIENT MAY ASK FOR" in Punk::OAuth2::Server.
client_get
my $client = $store->client_get('my-app');
# { client_id => ..., secret_digest => ..., redirect_uris => '[...]',
# scopes => ..., is_public => 0, ... } or undef
Returns the client row (redirect_uris as the stored JSON array string), or undef.
code_put / code_take
$store->code_put($code, {
client_id => 'my-app',
user_id => 'alice',
redirect_uri => 'https://my-app/callback',
scope => 'read',
nonce => $nonce, # OIDC, optional
code_challenge => $s256_challenge,
expires => time + 600,
});
my $rec = $store->code_take($code); # returns the record and
# deletes it (single use)
code_put stores sha256($code) with the bound fields; code_take returns the record once and removes it, so a code cannot be replayed.
refresh_put / refresh_take / refresh_rotate / refresh_revoke_family
$store->refresh_put($token, {
family_id => $family, # groups a rotation chain
client_id => 'my-app',
user_id => 'alice',
scope => 'read',
expires => time + 30*86400,
});
my $rec = $store->refresh_take($token); # look up (does not delete)
# { family_id, client_id, user_id, scope, expires,
# rotated_to, revoked } or undef
$store->refresh_rotate($token, $new_digest); # mark $token consumed
$store->refresh_revoke_family($family); # kill the whole chain
Rotation marks the old token with rotated_to; presenting an already-rotated token is treated as theft and revokes the family.
consent_get / consent_put
$store->consent_put('alice', 'my-app', 'read write');
my $c = $store->consent_get('alice', 'my-app');
# { user_id, client_id, scopes, ... } or undef
Records that a user approved a client for a set of scopes, so returning users skip the consent screen.
purge_expired
$store->purge_expired; # delete expired codes and refresh tokens
migrate
$store->migrate; # (re)create the tables; idempotent
dbh
my $dbh = $store->dbh; # the underlying DBI handle
SCHEMA
migrate (and auto_migrate => 1) creates four tables. The DDL below is what the shipped store runs; the types are SQLite-friendly and portable to Postgres. All timestamps are epoch seconds (INTEGER), and every credential column holds a base64url SHA-256 digest, never a raw value.
CREATE TABLE oauth2_clients (
client_id TEXT PRIMARY KEY,
secret_digest TEXT, -- NULL for a public client
name TEXT,
redirect_uris TEXT, -- JSON array of exact URIs
grant_types TEXT, -- space-separated
scopes TEXT, -- space-separated
auth_method TEXT, -- 'basic' or 'body'
is_public INTEGER DEFAULT 0,
created INTEGER
);
CREATE TABLE oauth2_codes (
code_digest TEXT PRIMARY KEY, -- sha256(authorization code)
client_id TEXT,
user_id TEXT,
redirect_uri TEXT,
scope TEXT,
nonce TEXT, -- OIDC, may be NULL
code_challenge TEXT, -- PKCE S256 challenge
expires INTEGER
);
CREATE TABLE oauth2_refresh (
token_digest TEXT PRIMARY KEY, -- sha256(refresh token)
family_id TEXT, -- groups a rotation chain
client_id TEXT,
user_id TEXT,
scope TEXT,
expires INTEGER,
rotated_to TEXT, -- set once the token is rotated
revoked INTEGER DEFAULT 0
);
CREATE TABLE oauth2_consents (
user_id TEXT,
client_id TEXT,
scopes TEXT,
created INTEGER,
PRIMARY KEY (user_id, client_id)
);
A row's life cycle mirrors the "METHODS": oauth2_codes rows are inserted by code_put and deleted by code_take (single use); oauth2_refresh rows are inserted by refresh_put, stamped with rotated_to by refresh_rotate, and flipped revoked by refresh_revoke_family; purge_expired removes any code or refresh row past its expires.
CREATING YOUR OWN STORE
The store passed to oauth2_server is any object implementing the contract below - a Redis store, an ORM-backed store, an in-memory store for tests. The authorization server only ever calls these methods.
The digest convention
The server checks a client secret by comparing base64url(sha256($presented_secret)) against the secret_digest you return from client_get. So store client secrets - and, for safety, codes and refresh tokens - as that same digest:
use Crypt::JWS qw(sha256 b64url);
sub digest { b64url(sha256($_[0])) }
The method contract
- client_get($client_id)
-
Return a hashref with
client_id,secret_digest(the base64url sha256 of the secret, or undef/empty for a public client),redirect_uris(an arrayref or a JSON array string - both are accepted),grant_typesandscopes, or undef if unknown.grant_typesandscopesare authorization decisions, not bookkeeping: the server refuses a grant or a scope that is not in them, and a row that omits them permits nothing. A store that drops those two fields on the way out will register clients that cannot do anything.is_publicis read too, and keeps a public client out of theclient_credentialsgrant even if it is registered for it. - code_put($code, \%rec) / code_take($code)
-
Store the record under a digest of the raw
$code.code_takemust return it once and then make it unavailable (single use). The record carriesclient_id,user_id,redirect_uri,scope,nonce,code_challenge,expires. - refresh_put($token, \%rec) / refresh_take($token)
-
Store under a digest of the raw
$token.refresh_takereturns the record (withfamily_id,client_id,user_id,scope,expires,rotated_to,revoked) or undef; it does not delete. - refresh_rotate($token, $new_digest)
-
Mark the record for
$tokenas consumed by setting itsrotated_toto the (already digested)$new_digest. A laterrefresh_takethat finds a non-emptyrotated_tosignals reuse. - refresh_revoke_family($family_id)
-
Set
revokedon every refresh record sharingfamily_id. - consent_get($user_id, $client_id) / consent_put($user_id, $client_id, $scopes)
-
Fetch/record a user's consent for a client.
- purge_expired()
-
Delete expired codes and refresh tokens.
A complete in-memory store
This is a working example (the one the test suite runs against the real server). Swap the hashes for your backend of choice:
package My::MemStore;
use Crypt::JWS qw(sha256 b64url);
sub _digest { b64url(sha256($_[0])) }
sub new {
bless {
clients=>{},
codes=>{},
refresh=>{},
consents=>{}
}, shift
}
sub client_put {
my ($self, $c) = @_;
$self->{clients}{ $c->{client_id} } = {
client_id => $c->{client_id},
secret_digest => (length($c->{secret} // ''))
? _digest($c->{secret}) : undef,
redirect_uris => $c->{redirect_uris} // [],
scopes => $c->{scopes},
};
}
sub client_get {
my ($self, $id) = @_;
my $c = $self->{clients}{$id} or return undef;
return { %$c };
}
sub code_put { $_[0]{codes}{ _digest($_[1]) } = { %{ $_[2] } } }
sub code_take { delete $_[0]{codes}{ _digest($_[1]) } }
sub refresh_put {
my ($self, $token, $rec) = @_;
$self->{refresh}{ _digest($token) } =
{ %$rec, revoked => 0, rotated_to => undef };
}
sub refresh_take {
my $r = $_[0]{refresh}{ _digest($_[1]) } or return undef;
return { %$r };
}
sub refresh_rotate {
my ($self, $token, $new) = @_;
my $r = $self->{refresh}{ _digest($token) } or return;
$r->{rotated_to} = $new;
}
sub refresh_revoke_family {
my ($self, $fam) = @_;
($_->{family_id} // '') eq $fam and $_->{revoked} = 1
for values %{ $self->{refresh} };
}
sub consent_get { $_[0]{consents}{"$_[1]\0$_[2]"} }
sub consent_put {
$_[0]{consents}{"$_[1]\0$_[2]"} =
{ user_id => $_[1], client_id => $_[2], scopes => $_[3] };
}
sub purge_expired { } # no-op for the demo
Then: oauth2_server '/oauth' => { ..., store => My::MemStore->new }.
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)