Storage seam for the RFC 8628 device authorization grant.
Unlike Attesto.CodeStore (a single-use code consumed by an atomic take/1),
a device code is a mutable record that lives through a small state machine —
pending → (approved | denied) → consumed — while the device polls the
token endpoint and the user approves on a second device. Every state
transition is security-critical, so each MUST be a single atomic operation
guarded on the current state, never an app-level read-then-write:
approve/2/deny/2movepending→approved/deniedonly — an already-decided or expired code is refused, so the user's decision is taken exactly once.poll/2enforces the RFC 8628 §3.5 minimum poll interval as one atomic conditional update oflast_polled_at, returning the record's current state in the same step (no separate read that could race an approval).consume/2movesapproved→consumedonly, so an approved device code mints exactly one token family even under concurrent polls.
The plaintext device_code is never stored; only its Attesto.Secret.hash/1.
The user_code is stored in its normalized form (see
Attesto.DeviceCode.normalize_user_code/2).
Record shape
A stored record is a map with:
:device_code_hash-Attesto.Secret.hash/1of the device code (the poll lookup key).:user_code- the normalized user code (the verification lookup key).:data- the issue-time context bound to the code:%{client_id, scope, resource, dpop_jkt}(scope/resourcelists;dpop_jktoptional).:status-:pending|:approved|:denied|:consumed.:subject- the approved resource owner (nil until approved).:granted_scope/:granted_claims- what the user actually authorized at approval (nil/absent until approved).:expires_at- absolute expiry, unix seconds.:last_polled_at- unix seconds of the last accepted poll, or nil before the first poll.
Summary
Types
A stored device-code record (see the module docs).
The non-consuming verification-page view of a pending device code.
Callbacks
Atomically move a pending code to approved, binding the resolved
:subject, :granted_scope, and :granted_claims. MUST refuse a code that
is not currently pending ({:error, :already_decided}) or unknown
({:error, :not_found}); {:error, :expired} for an expired pending code.
Implement as one guarded UPDATE ... WHERE status = 'pending' RETURNING.
Atomically move an approved code to consumed (single use), returning the
record as it stood. MUST update only when status = 'approved', so a second
redemption of the same device code (concurrent or sequential) returns
:error. Implement as one guarded UPDATE ... SET status = 'consumed' WHERE status = 'approved' RETURNING.
Atomically move a pending code to denied. Same guard/return contract as
approve/2.
Non-consuming read of the record for user_code, for the verification page to
show the user what they are about to approve. Returns :error when unknown.
Enforce the RFC 8628 §3.5 minimum poll interval and return the code's current
state, in one atomic step. opts carries :now (unix seconds) and
:interval (seconds). Returns {:ok, entry} when the poll is accepted
(updating :last_polled_at to :now), {:error, :slow_down} when the caller
polled faster than :interval, or :error when the device code is unknown.
Implement as one conditional UPDATE ... SET last_polled_at = now WHERE device_code_hash = $1 AND (last_polled_at IS NULL OR last_polled_at <= now - interval) RETURNING, distinguishing unknown from slow_down by a follow-up
existence check (both are non-mint outcomes, so that check is not a race).
Persist a new pending device-code record. Returns {:error, :user_code_taken}
when the record's user_code collides with a live one (so Attesto.DeviceCode
can retry with a fresh code); a device_code_hash collision is a CSPRNG-grade
impossibility and may raise.
Types
@type device_code_hash() :: String.t()
@type entry() :: %{ :device_code_hash => device_code_hash(), :user_code => user_code(), :data => map(), :status => :pending | :approved | :denied | :consumed, :expires_at => non_neg_integer(), optional(:subject) => String.t() | nil, optional(:granted_scope) => [String.t()] | nil, optional(:granted_claims) => map() | nil, optional(:last_polled_at) => non_neg_integer() | nil }
A stored device-code record (see the module docs).
@type pending_view() :: %{ user_code: user_code(), client_id: String.t() | nil, scope: [String.t()], resource: [String.t()], status: :pending | :approved | :denied | :consumed, expires_at: non_neg_integer() }
The non-consuming verification-page view of a pending device code.
@type user_code() :: String.t()
Callbacks
@callback approve(user_code(), approval :: map()) :: :ok | {:error, :not_found | :already_decided | :expired}
Atomically move a pending code to approved, binding the resolved
:subject, :granted_scope, and :granted_claims. MUST refuse a code that
is not currently pending ({:error, :already_decided}) or unknown
({:error, :not_found}); {:error, :expired} for an expired pending code.
Implement as one guarded UPDATE ... WHERE status = 'pending' RETURNING.
@callback consume(device_code_hash(), opts :: map()) :: {:ok, entry()} | :error
Atomically move an approved code to consumed (single use), returning the
record as it stood. MUST update only when status = 'approved', so a second
redemption of the same device code (concurrent or sequential) returns
:error. Implement as one guarded UPDATE ... SET status = 'consumed' WHERE status = 'approved' RETURNING.
@callback deny(user_code()) :: :ok | {:error, :not_found | :already_decided | :expired}
Atomically move a pending code to denied. Same guard/return contract as
approve/2.
@callback lookup_user_code(user_code()) :: {:ok, pending_view()} | :error
Non-consuming read of the record for user_code, for the verification page to
show the user what they are about to approve. Returns :error when unknown.
@callback poll(device_code_hash(), opts :: map()) :: {:ok, entry()} | {:error, :slow_down} | :error
Enforce the RFC 8628 §3.5 minimum poll interval and return the code's current
state, in one atomic step. opts carries :now (unix seconds) and
:interval (seconds). Returns {:ok, entry} when the poll is accepted
(updating :last_polled_at to :now), {:error, :slow_down} when the caller
polled faster than :interval, or :error when the device code is unknown.
Implement as one conditional UPDATE ... SET last_polled_at = now WHERE device_code_hash = $1 AND (last_polled_at IS NULL OR last_polled_at <= now - interval) RETURNING, distinguishing unknown from slow_down by a follow-up
existence check (both are non-mint outcomes, so that check is not a race).
@callback put(entry()) :: :ok | {:error, :user_code_taken}
Persist a new pending device-code record. Returns {:error, :user_code_taken}
when the record's user_code collides with a live one (so Attesto.DeviceCode
can retry with a fresh code); a device_code_hash collision is a CSPRNG-grade
impossibility and may raise.