NAME
Catalyst::Plugin::OAuth2::AuthorizationServer::Role::Store - storage contract
DESCRIPTION
Storage-agnostic persistence contract for the Authorization Server. The host app provides an object consuming this Role. All times are epoch seconds.
REQUIRED METHODS
create_client( \%metadata )
Persist a new client (the engine has already generated and inserted client_id into the metadata). Return the stored client hashref (must contain client_id and redirect_uris).
find_client( $client_id )
Return the stored client hashref for $client_id, or undef if unknown.
save_authorization_request( $request_id, \%data, $expires_at )
Persist the validated authorize request under the opaque $request_id with an absolute $expires_at. Return true.
take_authorization_request( $request_id )
Atomically fetch-and-delete (single-use) the saved request. Return the \%data hashref, or undef if missing or expired. The atomic fetch-and-delete requirement prevents authorization-request replay.
create_auth_code( $code, \%binding, $expires_at )
Persist a single-use authorization code. \%binding carries client_id, subject, redirect_uri, code_challenge, scope, resource. Return true.
consume_auth_code( $code )
Atomically fetch-and-delete the code's binding (single-use). Return the \%binding, or undef if unknown/expired/already used. The same atomic fetch-and-delete requirement prevents authorization-code replay.
create_refresh_token( $token_hash, \%binding, $expires_at )
Persist a refresh token by its hash (never the raw token). \%binding carries client_id, subject, scope, resource and family_id. Returns true when the token was persisted.
Returns false, persisting nothing, if family_id names a revoked family. A rotation already in flight when revoke_family runs would otherwise create its successor afterwards, leaving a live token in a family the server believes it killed, and an attacker who races two refreshes with a stolen token can force exactly that.
The check and the insert MUST be mutually exclusive with revoke_family for the same family. Being one method call is what makes that achievable, but it is not by itself sufficient, and a single INSERT ... WHERE NOT EXISTS against the revoked-families record is not enough under a weak isolation level. Under READ COMMITTED the two interleave as textbook write skew:
revoke_familyinserts its marker, still uncommitted.revoke_familysweeps the family's rows; the successor does not exist yet, so it sweeps nothing.create_refresh_token'sNOT EXISTScannot see the uncommitted marker, so it inserts the successor live.Both commit. The successor is live in a revoked family, which is the very race this rule exists to prevent.
Use SERIALIZABLE for both paths, or have both take the same per-family lock (for example SELECT ... FOR UPDATE on the family row) before reading or writing. Whichever you choose, the two operations must not be able to run concurrently for one family.
family_id is an opaque string identifying the rotation chain this token belongs to: it is minted when an authorization code is exchanged and inherited by every token rotated from it. The Store MUST persist it and MUST be able to find rows by it (see revoke_family).
rotate_refresh_token( $token_hash )
Atomically validate-and-revoke the refresh token identified by $token_hash. Returns one of three outcomes:
undef-
Unknown or expired. Not a replay: the engine answers
invalid_grantand does nothing else. { binding => \%binding }-
The token was live and is now revoked. The engine mints the next pair.
{ binding => \%binding, reused => 1 }-
The token is known but was already revoked: a replay. The binding is returned so the engine can read
family_idoff it and revoke the family.
Retention: a rotated token MUST be retained until its original $expires_at, marked revoked rather than deleted. Pruning earlier silently disables reuse detection: the replay degrades to undef, the engine reads it as unknown, and the compromised family survives. Pruning after $expires_at is the host application's job.
Concurrency note: each Store method MUST be atomic in itself; a non-atomic rotate_refresh_token enables refresh-token replay under concurrent requests. The engine calls create_refresh_token immediately after rotate_refresh_token, and it cannot hold a transaction across the two: they are separate calls, so another request's revoke_family can land between them. What makes the pair safe is not an envelope around it but the rule that create_refresh_token refuses to persist into a revoked family, which turns that interleaving into a plain invalid_grant for the in-flight rotation. A crash between the two calls will invalidate the session; that is the intended failure direction.
revoke_family( $family_id )
Revoke every refresh token sharing $family_id, live or already tombstoned, and record the family itself as revoked so that a rotation in flight cannot create a successor into it afterwards (see create_refresh_token). Return the number newly revoked. Idempotent: revoking an already-revoked family returns 0 and is not an error.
An implementation may use a single flag for both "tombstoned by rotation" and "revoked by family": in that case an already-tombstoned token counts as already revoked, and is not counted again.
Retention: the revoked-family record MUST outlive every token that could still belong to the family, i.e. until the latest $expires_at among its members has passed. Pruning it earlier reopens the race it exists to close: a rotation in flight would find no marker and create its successor. The record is per family rather than per token, so it is bounded by authorizations, not by rotations. Pruning it once the family's tokens have expired is the host application's job, on the same rule as the rotated-token tombstones.
Called when rotate_refresh_token reports a replay. Revoking the family is the only defence the server has against a stolen refresh token, because it cannot tell the legitimate client from the attacker.
revoke_refresh_tokens_for_subject( $subject )
Revoke all refresh tokens for $subject (e.g. on deactivation or password change). Return the number revoked.