AttestoPhoenix.Schema.RefreshToken (AttestoPhoenix v0.6.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.13). Presenting a token consumes it and mints a successor in the same family; re-presenting an already-consumed token is the captured-token signal that revokes the whole family. Only the hash of each token is persisted, never the plaintext, so a leaked store yields no usable credentials.

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 map; 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.

Summary

Functions

Changeset that atomically claims (consumes) an unconsumed token.

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.

Types

t()

@type t() :: %AttestoPhoenix.Schema.RefreshToken{
  __meta__: 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(),
  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 that atomically claims (consumes) an unconsumed token.

The atomic primitive on which reuse detection depends (see Attesto.RefreshStore) is UPDATE ... SET consumed = true WHERE token_hash = $1 AND consumed = false. An Ecto-backed store runs this changeset inside such a guarded update so that two concurrent rotations cannot both observe the token as unconsumed: exactly one update affects a row, the other affects none and is reported as reuse.

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 not stored as a column; it is reported as 0 so the contract's record stays well-formed without the schema asserting lineage it does not track.