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