AttestoPhoenix.Store.EctoCodeStore (AttestoPhoenix v0.6.1)

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 a DELETE ... WHERE code_hash = $1 RETURNING ..., so the fetch and the delete are one statement. Exactly one of any number of racing redemptions sees the row; every other caller sees an empty result and gets :error. This holds across all nodes sharing the database, which the single-node ETS store cannot offer. 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

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

Atomically fetches and deletes the record for code_hash.

Functions

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.