Attesto.DeviceCode (Attesto v0.13.0)

Copy Markdown View Source

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

Types

What issue/3 hands back: the secret device code and the display user code.

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 pendingdenied, 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

issue_attrs()

@type issue_attrs() :: %{
  :client_id => String.t(),
  optional(:scope) => [String.t()],
  optional(:resource) => [String.t()],
  optional(:dpop_jkt) => String.t() | nil
}

issued()

@type issued() :: %{device_code: String.t(), user_code: String.t()}

What issue/3 hands back: the secret device code and the display user code.

redeem_error()

@type redeem_error() ::
  :authorization_pending
  | :slow_down
  | :expired_token
  | :access_denied
  | :invalid_grant

Functions

approve(store, user_code, approval, opts \\ [])

@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 pendingapproved. Returns :ok, or {:error, reason} (:not_found / :already_decided / :expired / :invalid_user_code / :invalid_subject).

deny(store, user_code, opts \\ [])

@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 pendingdenied, so the device's next poll receives access_denied.

generate_user_code(length \\ 8)

@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.

issue(store, attrs, opts \\ [])

@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.

lookup(store, 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.

normalize_user_code(user_code, opts \\ [])

@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.

redeem(store, device_code, params, opts \\ [])

@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.