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/1of 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 (0for 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).nilfor 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).nilfor a bearer token.:claims- opaque issuer context round-tripped into the next access token. A recursively portable JSON object with string keys; nevernil.:consumed- whether the token has already been rotated. The atomic transition of this flag (seeclaim_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_hashof the predecessor that minted this token, ornilfor 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
@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
@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.
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.
@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.
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.
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.