AttestoPhoenix.Schema.Authorization (AttestoPhoenix v0.6.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 unique lookup key.

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.
  • :cnf - the optional confirmation/key-binding map (RFC 7800). When present it holds a jkt (DPoP key thumbprint, RFC 9449 §6) and/or an x5t#S256 (mTLS certificate thumbprint, RFC 8705 §3.1); a bound code MUST be redeemed presenting the same binding.
  • :nonce - the OIDC request nonce (OpenID Connect Core §3.1.2.1), round-tripped into the eventual ID Token.
  • :claims - an opaque map of additional request context carried from the authorization request to redemption.

Lifecycle columns

  • :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 the store's atomic take; this column is an audit marker for a consumed-then-rejected redemption, not a second gate.
  • :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 map from a loaded row so the protocol layer can hydrate the authorization-code grant from record.data.

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 opaque 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 opaque grant :data, and the absolute expiry in unix seconds.

t()

@type t() :: %AttestoPhoenix.Schema.Authorization{
  __meta__: term(),
  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,
  expires_at: DateTime.t() | nil,
  inserted_at: DateTime.t() | nil,
  nonce: String.t() | nil,
  redirect_uri: 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 opaque grant :data, and an integer :expires_at in unix seconds. The fields inside :data (client, subject, scope, redirect URI, PKCE challenge, optional DPoP thumbprint, OIDC nonce, and request claims) are spread across the row's columns so they can be queried and audited individually while still round-tripping losslessly via to_record/1.

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); when a code_challenge_method is present it is constrained to S256 (RFC 7636 §4.3), and a challenge-less code stores a NULL method.

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 opaque grant :data map in exactly the shape the protocol layer expects, and the :expires_at utc_datetime is converted back to unix seconds. 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.