Ecto schema for the single-use authorization codes backing an
Attesto.CodeStore.
This is the persistent record shape behind the authorization-code grant
(RFC 6749 §4.1). The store layer mints one row per code at the
authorization endpoint and consumes it at the token endpoint; this module
only describes the row and translates it to and from the protocol struct
Attesto.AuthorizationCode.Grant. All protocol decisions (code
generation and hashing, PKCE verification, DPoP/mTLS binding checks,
expiry, single-use semantics) live in attesto; nothing here re-derives
them.
What is stored, and what is not
Only the hash of the code is persisted (:code_hash), never the
plaintext code handed to the client. The plaintext is a bearer secret
(RFC 6749 §10.5): a database disclosure must not yield a usable code, so
the column is the output of Attesto.Secret.hash/1 and is the primary
key (there is no surrogate id). Keying the table on it, rather than only
indexing it, also supplies the primary-key index selected by PostgreSQL
REPLICA IDENTITY DEFAULT. A logical publication needs that identity when
it includes this table's UPDATEs or DELETEs.
The remaining columns are the authorization-request context that must be reproduced at redemption time:
:client_id- the client the code was issued to (RFC 6749 §4.1.3: the code MUST be redeemed by that same client).:subject- the resource owner the code authenticates.:scope- the granted scope, a list of scope tokens.:redirect_uri- the registered redirect URI, compared by exact string match at redemption (RFC 6749 §3.1.2 / §4.1.3).:code_challenge/:code_challenge_method- the PKCE challenge and its transform (RFC 7636). OnlyS256is a valid method. The method column remains for database compatibility and auditability; the core grant data treatsS256as implicit and does not include this key.:cnf- the optional confirmation/key-binding map (RFC 7800). When present for an authorization-code row it is exactly%{"jkt" => jkt}(or the legacy atom-key form%{jkt: jkt}), wherejktis a canonical RFC 7638 SHA-256 thumbprint.nilmeans no binding. Other keys, mixed key styles, malformed thumbprints, and anx5t#S256-only binding are rejected; a bound code MUST be redeemed presenting the same DPoP key.:nonce- a legacy compatibility column for the OIDC requestnonce(OpenID Connect Core §3.1.2.1). Canonical grant data carries this value under:claims.:claims- a portable, lossless JSON object of additional request context carried from the authorization request to redemption. Keys are strings at every level; values are JSONnull, booleans, strings, exact-range integers, arrays, or nested objects. Floats and other VM terms are not persisted because the Ecto JSONB boundary must round-trip the grant context unchanged. The column uses Ecto redaction so ordinary struct and changeset inspection hides it. It carries the authentication context and, for a host that configures:authorization_code_private_context, that host's private state under the reserved key owned byAttestoPhoenix.AuthorizationCodePrivateContext.
Lifecycle columns
:family_id- the grant family this code will mint into, used to revoke descendants when a redeemed code is replayed.:access_token_jti/:access_token_expires_at- the access token produced by the successful code redemption. Stored only after issuance, and used to deny the token if the code is later replayed.:access_token_revoked_at- set when code reuse revokes that token.:expires_at- absolute expiry as autc_datetime. Authorization codes are short-lived (RFC 6749 §4.1.2 recommends a maximum of ten minutes).:consumed_at- set when the code is spent. The single-use contract (RFC 6749 §4.1.2) is enforced by an atomic claim in the store; this column also lets a later presentation be recognized as reuse instead of an unknown code.:consumed_success- whether the first presentation completed all redemption checks. Only successful redemption is replayed as reuse.:inserted_at- insertion timestamp.
Record bridge
Attesto.CodeStore exchanges plain maps with a :code_hash, a
:data map, and an integer :expires_at (unix seconds). from_record/2
builds an Ecto changeset from such a map for insertion, and to_record/1
rebuilds the exact nine-key canonical data map that the protocol layer uses
to hydrate the authorization-code grant. The database-only PKCE-method and
legacy nonce columns are not returned as sibling data keys.
Table name and prefix
The table is attesto_authorization_codes by default and is namespaced
by the optional schema prefix passed via from_record/2's :prefix
option (or the schema-wide prefix configured through
AttestoPhoenix.Config), letting a host isolate the
authorization-server tables in their own schema.
Summary
Types
The plain map exchanged with Attesto.CodeStore: the code hash, the
grant :data, and the absolute expiry in unix seconds.
A persisted authorization-code row.
Functions
The only accepted PKCE code-challenge method (RFC 7636 §4.3, S256).
Builds an insertable changeset from a Attesto.CodeStore record map.
The default table name for this schema.
Rebuilds the Attesto.CodeStore record map from a loaded row.
Types
The plain map exchanged with Attesto.CodeStore: the code hash, the
grant :data, and the absolute expiry in unix seconds.
@type t() :: %AttestoPhoenix.Schema.Authorization{ __meta__: term(), access_token_expires_at: DateTime.t() | nil, access_token_jti: String.t() | nil, access_token_revoked_at: DateTime.t() | nil, claims: map() | nil, client_id: String.t() | nil, cnf: map() | nil, code_challenge: String.t() | nil, code_challenge_method: String.t() | nil, code_hash: String.t() | nil, consumed_at: DateTime.t() | nil, consumed_success: boolean(), expires_at: DateTime.t() | nil, family_id: String.t() | nil, inserted_at: DateTime.t() | nil, nonce: String.t() | nil, redirect_uri: String.t() | nil, resource: [String.t()] | nil, scope: [String.t()] | nil, subject: String.t() | nil }
A persisted authorization-code row.
Functions
@spec code_challenge_method() :: String.t()
The only accepted PKCE code-challenge method (RFC 7636 §4.3, S256).
@spec from_record(store_record(), keyword()) :: Ecto.Changeset.t()
Builds an insertable changeset from a Attesto.CodeStore record map.
record is the map the protocol layer persists: a :code_hash, the grant
:data, and an integer :expires_at in unix seconds. The
fields inside :data (client, subject, scope, redirect URI, PKCE
challenge, optional DPoP thumbprint, claims, and family) are spread across
the row's columns so they can be queried and audited individually. The
canonical core data map keeps the OIDC nonce inside :claims; the
database-only :nonce column remains readable for legacy rows. When
populated, it is promoted into a valid claims map as the authoritative
string-key nonce after removing any legacy atom- or string-key nonce entries.
When the column is NULL, a canonical string-key nonce already in claims is
preserved, while atom-key or mixed maps remain malformed for core validation.
A malformed NULL claims value is preserved, even when the legacy nonce is
populated; it is not repaired. A top-level :nonce in the core data map is
not canonical and is rejected.
Options:
:prefix- the Ecto schema prefix (database schema) to write the row into. Defaults to no prefix.:now- the insertion clock as aDateTime. Defaults toDateTime.utc_now/0. Provided for deterministic tests.
Validation is fail-closed: a missing required field (hash, client,
subject, redirect URI, or expiry) is rejected rather than defaulted. PKCE
is optional at persistence (a confidential client the host exempted from
PKCE via Attesto.AuthorizationRequest's :require_pkce issues a code with
no challenge). A challenge implies S256 (RFC 7636 §4.3), and a
challenge-less code stores a NULL database method; the database-only method
column is not accepted as a sibling in canonical core data.
@spec table() :: String.t()
The default table name for this schema.
@spec to_record(t()) :: store_record()
Rebuilds the Attesto.CodeStore record map from a loaded row.
The columns are folded back into the exact nine-key canonical grant :data
map the protocol layer expects. code_challenge_method is implicit as
S256 in that contract, and a non-NULL legacy nonce column is promoted
into claims as the authoritative string-key nonce, removing any atom-key
or conflicting string-key nonce. If the legacy column is NULL, a canonical
string-key nonce already present in claims is preserved; atom-key or mixed
nonce maps remain malformed so the core JSON-object validation rejects them.
A NULL claims value remains malformed even when the legacy nonce column is
populated; it is not repaired into a new map. The row's confirmation binding is accepted only when
it is nil, an exact string-key jkt map, or an exact legacy atom-key jkt
map containing a canonical thumbprint; malformed or unsupported bindings
raise a fixed, value-free ArgumentError rather than becoming nil. The
:expires_at utc_datetime is converted back to unix seconds. Database
non-NULL constraints keep scope, resource, and claims populated for normal
rows; malformed NULL values are preserved for the protocol layer to reject,
rather than defaulted into a valid-looking grant. The protocol layer
re-checks expiry after taking the record, so a row that is past :expires_at
is still returned here and rejected downstream.