AttestoPhoenix.Schema.Authorization (AttestoPhoenix v3.2.1)

Copy Markdown View Source

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). Only S256 is a valid method. The method column remains for database compatibility and auditability; the core grant data treats S256 as 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}), where jkt is a canonical RFC 7638 SHA-256 thumbprint. nil means no binding. Other keys, mixed key styles, malformed thumbprints, and an x5t#S256-only binding are rejected; a bound code MUST be redeemed presenting the same DPoP key.
  • :nonce - a legacy compatibility column for the OIDC request nonce (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 JSON null, 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 by AttestoPhoenix.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 a utc_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.

t()

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

store_record()

@type store_record() :: %{code_hash: String.t(), data: map(), expires_at: integer()}

The plain map exchanged with Attesto.CodeStore: the code hash, the grant :data, and the absolute expiry in unix seconds.

t()

@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

code_challenge_method()

@spec code_challenge_method() :: String.t()

The only accepted PKCE code-challenge method (RFC 7636 §4.3, S256).

from_record(record, opts \\ [])

@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 a DateTime. Defaults to DateTime.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.

table()

@spec table() :: String.t()

The default table name for this schema.

to_record(row)

@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.