AttestoPhoenix.Store.EctoCodeStore (AttestoPhoenix v3.2.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 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 unique database key is the Attesto.Secret.hash/1 digest of the code. The column layout and the record bridge live in AttestoPhoenix.Schema.Authorization; the bridge emits core's canonical authorization-code data map, with S256 implicit and the OIDC nonce inside claims. This module owns the atomic code operations and the optional access-token linkage used by replay containment. Refresh-flow linkage is selected by code hash before the core binds the row to its newly issued refresh family.

The repository module is resolved at call time from the validated request-local configuration, then the host configuration selected by :otp_app; the package-level :attesto_phoenix setting is only the legacy fallback when no :otp_app pointer exists. A store with no backing repository can make no guarantees, so a missing :repo fails closed rather than silently no-opping.

Query observability

The claims column carries the authentication context and, for a host that configures :authorization_code_private_context, that host's private authorization state. Ecto SQL query telemetry reports params, cast params, and decoded results, and every authorization-row query carries at least one security-sensitive value - a code hash, subject, family ID, or access-token JTI - even when it does not touch the claims column. So every operation in this store suppresses both application SQL logging and the [:my_app, :repo, :query] telemetry event. The reuse-detection reads additionally select only the columns they report, keeping the claims JSONB out of the decoded row as defence in depth.

Suppression is unconditional

It applies to every deployment, including one that never enables :authorization_code_private_context. Repository resolution can use the request-local AttestoPhoenix.Config, but observability does not vary with that option; standalone store calls may have no request-local config at all. Custom stores and database-server logging remain the host's responsibility.

Summary

Functions

Reads the live (unconsumed) record for code_hash WITHOUT consuming it.

Marks a successfully redeemed code as reuse-trackable.

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

Atomically fetches and consumes the record for code_hash.

Functions

get(code_hash)

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

Reads the live (unconsumed) record for code_hash WITHOUT consuming it.

Returns {:ok, entry} for a present, not-yet-consumed code, or :error otherwise. Unlike take/1 this is a plain SELECT - it does NOT mark the code consumed - so it is safe for read-only pre-checks at the token endpoint (e.g. a holder-of-key / DPoP requirement, RFC 9449 §10) without burning single use.

mark_consumed(code_hash, arg2)

Marks a successfully redeemed code as reuse-trackable.

The token endpoint calls this during successful finalization, after Attesto.AuthorizationCode.redeem/4 and all downstream issuance steps have succeeded. A later take/1 for the same hash can then surface {:error, :consumed, meta} instead of treating the replay as an unknown code. When the core's issue_refresh_and_finalize/6 composition supplies the family actually issued, this update binds that family to the consumed authorization row atomically with the success marker. That lets access-token revocation index the minted token under the same family used by replay containment without accepting a caller-supplied family identifier.

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 record returned by take/1 contains core's canonical data keys only; database compatibility columns for the PKCE method and legacy top-level nonce are not emitted.

The hash is the unique database 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. A unique-constraint violation raises a sanitized Ecto.InvalidChangesetError rather than exposing the failed changeset, which can contain authorization claims and host-private context. Fail closed; no upsert.

take(code_hash)

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

Atomically fetches and consumes the record for code_hash.

Returns {:ok, entry} when the row existed and was still live, {:error, :consumed, meta} when it was already successfully redeemed, or :error when it was absent. The fetch and the consume mark are one indivisible statement (UPDATE ... WHERE consumed_at IS NULL RETURNING ...), so the single-use contract of Attesto.CodeStore holds against concurrent redemptions.

The loaded row is folded back into the :code_hash / canonical :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.