AttestoPhoenix.Store.EctoCodeStore (AttestoPhoenix v0.6.18)

Copy Markdown View Source

Ecto implementation of the Attesto.CodeStore behaviour.

Authorization codes are single-use (RFC 6749 §4.1.2) and, with PKCE mandatory (RFC 7636), the code is the only browser-deliverable secret in the authorization-code flow. The single-use guarantee therefore cannot be advisory: it must be enforced by the store so that two concurrent redemptions of one code cannot both succeed.

take/1 issues an UPDATE ... WHERE consumed_at IS NULL RETURNING ..., so the fetch and the consumption mark are one statement. Exactly one of any number of racing redemptions sees the row as fresh; later callers either get :error for an unsuccessful first presentation or {:error, :consumed, meta} for a code that was already successfully redeemed. This holds across all nodes sharing the database. The code is consumed even when the caller later rejects the redemption (mismatched redirect URI, failed PKCE verifier): a code presented once is spent, which denies an attacker repeated validation attempts against a captured code.

The plaintext code is never persisted; the primary key is the Attesto.Secret.hash/1 digest of the code. The column layout and the record bridge live in AttestoPhoenix.Schema.Authorization; this module only owns the two atomic database operations.

The repository module is supplied by the host application (:repo under the :attesto_phoenix app) and is read at call time. A store with no backing repository can make no guarantees, so a missing :repo fails closed rather than silently no-opping.

Summary

Functions

Marks a successfully redeemed code as reuse-trackable.

Persists an authorization-code record keyed by its :code_hash.

Atomically fetches and deletes the record for code_hash.

Functions

mark_consumed(code_hash, meta)

Marks a successfully redeemed code as reuse-trackable.

Attesto.AuthorizationCode.redeem/4 calls this after every validation step has passed. A later take/1 for the same hash can then surface {:error, :consumed, meta} instead of treating the replay as an unknown code.

put(record)

@spec put(Attesto.CodeStore.entry()) :: :ok

Persists an authorization-code record keyed by its :code_hash.

The record is the plain map the protocol layer hands over: a :code_hash, the opaque grant :data, and an integer :expires_at in unix seconds. AttestoPhoenix.Schema.Authorization.from_record/1 spreads it across the row's columns and validates it fail-closed (missing required field or a non-S256 PKCE method is rejected, not defaulted).

The hash is the primary key, so a duplicate insert is a caller bug: Attesto.AuthorizationCode derives the hash from freshly generated random bytes, so a collision means the random source repeated or the same entry was put twice. insert!/1 raises on the unique-constraint violation rather than silently overwriting an existing, possibly already-issued, code. Fail closed; no upsert.

take(code_hash)

@spec take(Attesto.CodeStore.code_hash()) :: {:ok, Attesto.CodeStore.entry()} | :error

Atomically fetches and deletes the record for code_hash.

Returns {:ok, entry} when the row existed (and is now gone), or :error when it was absent. The fetch and the delete are one indivisible statement (DELETE ... RETURNING), so the single-use contract of Attesto.CodeStore holds against concurrent redemptions.

The loaded row is folded back into the :code_hash / :data / :expires_at (unix seconds) map via AttestoPhoenix.Schema.Authorization.to_record/1. Expiry is not checked here: Attesto.AuthorizationCode re-checks :expires_at after take/1, and consuming the row regardless of freshness preserves single use, since an expired-but-present code is still spent on first presentation.