AttestoPhoenix.Schema.RefreshToken (AttestoPhoenix v3.2.1)

Copy Markdown View Source

Ecto schema for the refresh-token records that back an Ecto-backed Attesto.RefreshStore.

Refresh tokens are rotated single-use credentials (RFC 6749 §6, §10.4; OAuth 2.0 Security BCP §4.14). Presenting a token consumes it and mints a successor in the same family. An immediate matching retry may recover the same still-unused successor inside its fixed grace window; an already- consumed token presented outside those conditions is the captured-token signal that revokes the whole family. Token hashes are persisted instead of the presented plaintext. When retry grace is enabled, the already-minted successor is retained as encrypted, credential-equivalent state only until its fixed retry deadline; the supervised store sweeper irreversibly removes the ciphertext on its next run.

Successor recovery accepts the stopped-cutover legacy v1 envelope (v plus authenticated ciphertext) and the current v2 envelope (those fields plus the bound child hash and matching retry deadline). v1 uses the package-wide authenticated-data value, so its binding is deliberately reduced: it is not cryptographically tied to a parent or family. The Ecto store compensates by requiring a matching durable child lineage before exposing a v1 successor. It also accepts the exact strict v1 tombstone (v, retry_until, recoverable: false). Legacy v1 ciphertext payloads may omit the deadline and derive it from persisted consumption time and configured grace; unsupported or extra payload members are rejected.

Columns

  • :token_hash - Attesto.Secret.hash/1 of the token. The lookup key; a unique index enforces one row per token.
  • :family_id - groups every token descended from one authorization grant. Revoked together on reuse detection.
  • :generation - rotation generation within the family (0 for the first token).
  • :client_id - the OAuth client the token was issued to (RFC 6749 §10.4 requires rotation to be confined to the issuing client). nil for a token with no client binding.
  • :subject - the resource owner the token authorizes.
  • :scope - the granted scope as a list of strings (RFC 6749 §3.3); a successor's scope MUST be a subset of its predecessor's.
  • :cnf - the RFC 7800 confirmation claim binding the token to a proof of possession (e.g. %{"jkt" => thumbprint} for a DPoP key, RFC 9449; %{"x5t#S256" => thumbprint} for an mTLS certificate, RFC 8705). nil for a bearer token.
  • :claims - opaque issuer context round-tripped into the next access token. A recursively portable JSON object with string keys; never nil.
  • :consumed - whether the token has already been rotated. The atomic transition of this flag (see claim_changeset/1) is what makes reuse detection reliable.
  • :consumed_at - when the token was rotated, used for the short idempotency window on honest refresh retries.
  • :successor - encrypted already-minted successor returned during an idempotent retry. The plaintext successor token is never stored directly in the database.
  • :family_revoked - whether the token's family has been revoked. A revoked family fails closed: no row in it may be rotated, and no successor may be inserted into it (sticky revocation).
  • :expires_at - absolute expiry. A token at or past its expiry is refused without being consumed.
  • :parent_hash - the :token_hash of the predecessor that minted this token, or nil for the first token in a family. Diagnostic lineage; it is never used as a lookup key.
  • :inserted_at - issuance time, set on insert.

Confirmation translation

Attesto.RefreshToken carries the proof-of-possession binding as a :dpop_jkt thumbprint inside its opaque context map. This schema persists the binding as a structured :cnf confirmation so the same column can hold any RFC 7800 member. from_store_record/2 folds a :dpop_jkt into a cnf, and to_store_record/1 unfolds it back, so the protocol layer continues to speak :dpop_jkt while storage stays confirmation-shaped. The bridge accepts only nil or an exact one-member DPoP confirmation (%{"jkt" => thumbprint} from JSONB, or %{jkt: thumbprint} from an atom-keyed compatibility row), where the thumbprint is canonical. An unsupported RFC 7800 member, an extra confirmation member, or a malformed thumbprint remains invalid on recovery; it is never projected to an unbound (nil) token.

Summary

Functions

Changeset for the parent mutation inside an atomic rotation.

Build the insert attributes for a store record handed in by Attesto.RefreshToken.

Changeset for inserting a new (unconsumed) refresh-token record.

Render a persisted row back into the Attesto.RefreshStore record shape the protocol layer expects.

Render a row with optional stopped-cutover compatibility settings.

Types

t()

@type t() :: %AttestoPhoenix.Schema.RefreshToken{
  __meta__: term(),
  acr: term(),
  auth_time: term(),
  claims: term(),
  client_id: term(),
  cnf: term(),
  consumed: term(),
  consumed_at: term(),
  expires_at: term(),
  family_id: term(),
  family_revoked: term(),
  generation: term(),
  id: term(),
  inserted_at: term(),
  parent_hash: term(),
  resource: term(),
  scope: term(),
  subject: term(),
  successor: term(),
  token_hash: term()
}

Functions

claim_changeset(record, consumed_at)

@spec claim_changeset(t(), DateTime.t()) :: Ecto.Changeset.t()

Changeset for the parent mutation inside an atomic rotation.

AttestoPhoenix.Store.EctoRefreshStore.rotate/4 applies this change inside the family-serialized transaction that also persists the successor row and its retry state. Keeping the mutation in a changeset makes the persisted consumed/consumed_at pair explicit while the store owns transaction and locking semantics.

from_store_record(record, opts \\ [])

@spec from_store_record(map(), keyword()) :: map()

Build the insert attributes for a store record handed in by Attesto.RefreshToken.

The protocol layer's record is %{token_hash, family_id, generation, data, expires_at, consumed} where data is the opaque context (%{subject, scope, client_id, dpop_jkt, claims}). This flattens data into the schema's columns, translating :dpop_jkt into an RFC 7800 :cnf confirmation, and renders :expires_at (unix seconds in the contract) as a DateTime. :parent_hash is taken from opts[:parent_hash] when the store threads predecessor lineage; the contract does not carry it.

insert_changeset(struct \\ %__MODULE__{}, attrs)

@spec insert_changeset(t(), map()) :: Ecto.Changeset.t()

Changeset for inserting a new (unconsumed) refresh-token record.

Validates the columns the store contract requires and enforces single-use storage via the unique constraint on :token_hash. A new record is always unconsumed and never starts revoked; passing either flag as true is refused so an insert cannot smuggle a token into a consumed or revoked state.

to_store_record(row)

@spec to_store_record(t()) :: map()

Render a persisted row back into the Attesto.RefreshStore record shape the protocol layer expects.

Inverse of from_store_record/2: it rebuilds the opaque :data context (unfolding the :cnf confirmation back into :dpop_jkt) and renders :expires_at back to unix seconds. :generation is read from its persisted column without a fallback, so a malformed persisted value cannot be widened into a valid generation.

to_store_record(row, opts)

@spec to_store_record(t(), keyword()) :: map()

Render a row with optional stopped-cutover compatibility settings.

:legacy_grace_seconds supplies the currently configured retry grace for a v1 encrypted successor that predates persisted retry deadlines. The derived deadline is bounded by both consumed_at + grace and the parent expiry, so reading an old row never extends its authority.