RFC 8628 Device Authorization Grant — the conn-free core.
This is the storage-backed primitive behind the device flow, the analogue of
Attesto.AuthorizationCode for a browserless, redirect-less, user-present
grant. A headless/CLI client requests a device_code + a human-readable
user_code (issue/3), shows the user the user_code and a verification
URL, and polls the token endpoint (redeem/4) while the user approves on a
second device (approve/3 / deny/2 from the verification page).
Like Attesto.AuthorizationCode, every decision is data-only: this module
reads no Plug.Conn, no clock except the passed :now, and drives all state
through an Attesto.DeviceCodeStore. The polling state machine and its exact
RFC 8628 §3.5 error vocabulary (authorization_pending / slow_down /
expired_token / access_denied) live here.
user_code
The user_code is generated from an ambiguity-free base-20 alphabet
(RFC 8628 §6.1: no vowels — so no accidental words — and no visually
confusable 0/O/1/I), 8 characters by default (~34.6 bits), displayed
hyphenated (BCDF-GHJK). It is normalized and charset-validated here before
any store lookup (normalize_user_code/2): upper-cased, separators
stripped, and rejected outright if it falls outside the alphabet or length —
so attacker-shaped input never reaches the store's unique-index query, the
same fail-closed discipline Attesto.ResourceIndicator.validate/1 applies.
Summary
Functions
Approve a pending device code from the verification page (RFC 8628 §3.3), binding the resolved resource owner.
Deny a pending device code from the verification page (RFC 8628 §3.3): atomic
pending → denied, so the device's next poll receives access_denied.
Generate a fresh display-formatted user code (BCDF-GHJK).
Issue a device code + user code for an authenticated device-authorization request (RFC 8628 §3.1 / §3.2).
Non-consuming lookup of a pending device code by user_code, for the
verification page to show the user what they are approving. Returns
{:error, :invalid_user_code} for malformed input (before any store call) and
:error for an unknown code.
Normalize and charset-validate a user-entered user_code: upper-case, strip
separators (hyphens/whitespace), and reject anything outside the base-20
alphabet or the expected length. Fail-closed — returns
{:error, :invalid_user_code} rather than letting attacker-shaped input reach
a store lookup.
Redeem a device code at the token endpoint, running the RFC 8628 §3.5 polling state machine.
Types
What issue/3 hands back: the secret device code and the display user code.
@type redeem_error() ::
:authorization_pending
| :slow_down
| :expired_token
| :access_denied
| :invalid_grant
Functions
@spec approve(module(), String.t(), map(), keyword()) :: :ok | {:error, :not_found | :already_decided | :expired | :invalid_user_code | :invalid_subject}
Approve a pending device code from the verification page (RFC 8628 §3.3), binding the resolved resource owner.
approval carries :subject (required), and the granted :scope /
:claims. Atomic pending → approved. Returns :ok, or {:error, reason}
(:not_found / :already_decided / :expired / :invalid_user_code /
:invalid_subject).
@spec deny(module(), String.t(), keyword()) :: :ok | {:error, :not_found | :already_decided | :expired | :invalid_user_code}
Deny a pending device code from the verification page (RFC 8628 §3.3): atomic
pending → denied, so the device's next poll receives access_denied.
@spec generate_user_code(pos_integer()) :: String.t()
Generate a fresh display-formatted user code (BCDF-GHJK).
Each character is drawn from the base-20 alphabet using
:crypto.strong_rand_bytes/1 (a CSPRNG) with rejection sampling to keep the
draw uniform — the user_code is an online authorization handle, so it must
not come from the VM's non-cryptographic PRNG.
@spec issue(module(), issue_attrs(), keyword()) :: {:ok, issued()} | {:error, :invalid_client_id | :user_code_unavailable}
Issue a device code + user code for an authenticated device-authorization request (RFC 8628 §3.1 / §3.2).
attrs carries the issue-time binding: :client_id (required), and the
optional :scope / :resource (RFC 8707) / :dpop_jkt (RFC 9449 §10
pre-binding). Options: :ttl (seconds, default 600),
:user_code_length (default 8), and :now.
Returns {:ok, %{device_code: ..., user_code: ...}} with the plaintext device
code (only its hash is stored) and the display-formatted user code.
@spec lookup(module(), String.t()) :: {:ok, Attesto.DeviceCodeStore.pending_view()} | :error | {:error, :invalid_user_code}
Non-consuming lookup of a pending device code by user_code, for the
verification page to show the user what they are approving. Returns
{:error, :invalid_user_code} for malformed input (before any store call) and
:error for an unknown code.
@spec normalize_user_code( String.t(), keyword() ) :: {:ok, String.t()} | {:error, :invalid_user_code}
Normalize and charset-validate a user-entered user_code: upper-case, strip
separators (hyphens/whitespace), and reject anything outside the base-20
alphabet or the expected length. Fail-closed — returns
{:error, :invalid_user_code} rather than letting attacker-shaped input reach
a store lookup.
@spec redeem(module(), String.t(), map(), keyword()) :: {:ok, Attesto.DeviceCode.Grant.t()} | {:error, redeem_error()}
Redeem a device code at the token endpoint, running the RFC 8628 §3.5 polling state machine.
params carries the polling client's :client_id (matched against the
issue-time binding) and any :dpop_jkt (RFC 9449 holder-of-key, matched
against a pre-bound key). Options: :now and :interval (the minimum poll
interval in seconds, default 5).
Returns {:ok, %Attesto.DeviceCode.Grant{}} once the user has approved and the
code is single-use consumed, or {:error, reason} where reason is the exact
RFC 8628 §3.5 code the token endpoint renders verbatim:
:authorization_pending- the user has not yet approved.:slow_down- the device polled faster than:interval.:expired_token- the code's TTL elapsed (this wins over a stale approval).:access_denied- the user denied the request.:invalid_grant- unknown/garbage device code, client mismatch, DPoP mismatch, or an already-consumed code.