Skip to main content

lib/attesto/device_code.ex

defmodule Attesto.DeviceCode do
  @moduledoc """
  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.
  """

  alias Attesto.DeviceCode.Grant
  alias Attesto.Secret

  # RFC 8628 §6.1: a base-20 alphabet with no vowels (no accidental words) and no
  # visually ambiguous characters (0/O, 1/I/L). 20^8 ≈ 2.56e10 ≈ 34.6 bits.
  @user_code_alphabet ~c"BCDFGHJKLMNPQRSTVWXZ"
  @default_user_code_length 8
  @default_ttl_seconds 600
  @default_interval_seconds 5

  @typedoc "What `issue/3` hands back: the secret device code and the display user code."
  @type issued :: %{device_code: String.t(), user_code: String.t()}

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

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

  @doc """
  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 #{@default_ttl_seconds}),
  `:user_code_length` (default #{@default_user_code_length}), and `:now`.

  Returns `{:ok, %{device_code: ..., user_code: ...}}` with the plaintext device
  code (only its hash is stored) and the display-formatted user code.
  """
  # A `user_code` is short (≈34.6 bits), so a birthday collision against a live
  # code is rare but possible; retry with a fresh code a bounded number of times
  # rather than surfacing the store's uniqueness violation as an error.
  @user_code_collision_retries 5

  @spec issue(module(), issue_attrs(), keyword()) ::
          {:ok, issued()} | {:error, :invalid_client_id | :user_code_unavailable}
  def issue(store, attrs, opts \\ []) when is_atom(store) and is_map(attrs) and is_list(opts) do
    with :ok <- validate_issue_attrs(attrs) do
      length = Keyword.get(opts, :user_code_length, @default_user_code_length)
      ttl = Keyword.get(opts, :ttl, @default_ttl_seconds)
      device_code = Secret.generate()

      data = %{
        client_id: attrs.client_id,
        scope: Map.get(attrs, :scope, []),
        resource: Map.get(attrs, :resource, []),
        dpop_jkt: Map.get(attrs, :dpop_jkt)
      }

      put_with_retry(store, device_code, data, length, unix_now(opts) + ttl, @user_code_collision_retries)
    end
  end

  defp put_with_retry(_store, _device_code, _data, _length, _expires_at, 0), do: {:error, :user_code_unavailable}

  defp put_with_retry(store, device_code, data, length, expires_at, attempts_left) do
    user_code = generate_user_code(length)

    record = %{
      device_code_hash: Secret.hash(device_code),
      # Stored normalized (no separators); the display form is returned to the
      # client to show the user.
      user_code: normalize!(user_code),
      data: data,
      status: :pending,
      subject: nil,
      expires_at: expires_at,
      last_polled_at: nil
    }

    case store.put(record) do
      :ok -> {:ok, %{device_code: device_code, user_code: user_code}}
      {:error, :user_code_taken} -> put_with_retry(store, device_code, data, length, expires_at, attempts_left - 1)
    end
  end

  @doc """
  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 #{@default_interval_seconds}).

  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.
  """
  @spec redeem(module(), String.t(), map(), keyword()) :: {:ok, Grant.t()} | {:error, redeem_error()}
  def redeem(store, device_code, params, opts \\ [])
      when is_atom(store) and is_binary(device_code) and is_map(params) and is_list(opts) do
    hash = Secret.hash(device_code)
    poll_opts = %{now: unix_now(opts), interval: Keyword.get(opts, :interval, @default_interval_seconds)}

    case store.poll(hash, poll_opts) do
      {:ok, record} -> redeem_polled(store, hash, record, params, opts)
      {:error, :slow_down} -> {:error, :slow_down}
      :error -> {:error, :invalid_grant}
    end
  end

  # The poll was accepted. Apply the §3.5 precedence: expiry first (a stale
  # approval must not mint), then the binding checks, then the status outcome.
  # ALL validation runs on the polled record BEFORE `consume/2`, so a client- or
  # DPoP-mismatched poll is rejected without burning the single-use code; only an
  # approved, unexpired, correctly-bound code reaches the atomic consume.
  defp redeem_polled(store, hash, record, params, opts) do
    with :ok <- check_not_expired(record, opts),
         :ok <- check_client(record, params),
         :ok <- check_dpop(record, params),
         :ok <- check_status(record) do
      consume(store, hash, opts)
    end
  end

  defp consume(store, hash, opts) do
    case store.consume(hash, %{now: unix_now(opts)}) do
      {:ok, record} ->
        {:ok, Grant.from_record(record)}

      # Lost the consume race (a concurrent poll consumed it), or the status
      # moved out from under us between poll and consume.
      :error ->
        {:error, :invalid_grant}
    end
  end

  @doc """
  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 approve(module(), String.t(), map(), keyword()) ::
          :ok | {:error, :not_found | :already_decided | :expired | :invalid_user_code | :invalid_subject}
  def approve(store, user_code, approval, _opts \\ [])
      when is_atom(store) and is_binary(user_code) and is_map(approval) do
    with {:ok, normalized} <- normalize_user_code(user_code),
         :ok <- require_subject(approval) do
      store.approve(normalized, %{
        subject: approval.subject,
        granted_scope: Map.get(approval, :scope, []),
        granted_claims: Map.get(approval, :claims, %{})
      })
    end
  end

  @doc """
  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 deny(module(), String.t(), keyword()) ::
          :ok | {:error, :not_found | :already_decided | :expired | :invalid_user_code}
  def deny(store, user_code, _opts \\ []) when is_atom(store) and is_binary(user_code) do
    with {:ok, normalized} <- normalize_user_code(user_code) do
      store.deny(normalized)
    end
  end

  @doc """
  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 lookup(module(), String.t()) ::
          {:ok, Attesto.DeviceCodeStore.pending_view()} | :error | {:error, :invalid_user_code}
  def lookup(store, user_code) when is_atom(store) and is_binary(user_code) do
    case normalize_user_code(user_code) do
      {:ok, normalized} -> store.lookup_user_code(normalized)
      {:error, :invalid_user_code} = err -> err
    end
  end

  @doc """
  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 normalize_user_code(String.t(), keyword()) :: {:ok, String.t()} | {:error, :invalid_user_code}
  def normalize_user_code(user_code, opts \\ []) when is_binary(user_code) do
    length = Keyword.get(opts, :user_code_length, @default_user_code_length)

    normalized =
      user_code
      |> String.upcase()
      |> String.replace(~r/[\s-]/, "")

    cond do
      String.length(normalized) != length -> {:error, :invalid_user_code}
      not all_in_alphabet?(normalized) -> {:error, :invalid_user_code}
      true -> {:ok, normalized}
    end
  end

  @doc """
  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 generate_user_code(pos_integer()) :: String.t()
  def generate_user_code(length \\ @default_user_code_length) when is_integer(length) and length > 0 do
    code = for _ <- 1..length, into: "", do: <<random_alphabet_char()>>

    # Hyphenate into groups of 4 for readability (purely a display affordance;
    # the separator is stripped on input).
    code
    |> String.graphemes()
    |> Enum.chunk_every(4)
    |> Enum.map_join("-", &Enum.join/1)
  end

  # ----- internal -----

  defp validate_issue_attrs(%{client_id: client_id}) when is_binary(client_id) and client_id != "", do: :ok
  defp validate_issue_attrs(_attrs), do: {:error, :invalid_client_id}

  defp require_subject(%{subject: subject}) when is_binary(subject) and subject != "", do: :ok
  defp require_subject(_approval), do: {:error, :invalid_subject}

  defp check_not_expired(%{expires_at: expires_at}, opts) do
    if expires_at > unix_now(opts), do: :ok, else: {:error, :expired_token}
  end

  # RFC 8628 §3.4: the polling client must be the one the code was issued to.
  defp check_client(%{data: %{client_id: bound}}, %{client_id: presented}) do
    if is_binary(bound) and bound == presented, do: :ok, else: {:error, :invalid_grant}
  end

  defp check_client(_record, _params), do: {:error, :invalid_grant}

  defp check_status(%{status: :approved}), do: :ok
  defp check_status(%{status: :pending}), do: {:error, :authorization_pending}
  defp check_status(%{status: :denied}), do: {:error, :access_denied}
  # consumed (or anything else) → already used.
  defp check_status(_record), do: {:error, :invalid_grant}

  # RFC 9449 §10 holder-of-key: a code pre-bound to a DPoP key may be redeemed
  # only with a matching proof. An unbound code accepts any (or no) presented
  # key, which the caller may use to mint a DPoP-bound token.
  defp check_dpop(%{data: %{dpop_jkt: bound}}, params) when is_binary(bound) and bound != "" do
    if Map.get(params, :dpop_jkt) == bound, do: :ok, else: {:error, :invalid_grant}
  end

  defp check_dpop(_record, _params), do: :ok

  defp normalize!(user_code) do
    {:ok, normalized} = normalize_user_code(user_code)
    normalized
  end

  defp all_in_alphabet?(normalized) do
    String.to_charlist(normalized) |> Enum.all?(&(&1 in @user_code_alphabet))
  end

  @alphabet_size length(@user_code_alphabet)
  # Rejection sampling: the largest multiple of the alphabet size that fits in a
  # byte (240 for a 20-char alphabet); bytes at or above it are discarded so the
  # modulo is unbiased.
  @rejection_ceiling div(256, @alphabet_size) * @alphabet_size

  defp random_alphabet_char do
    case :crypto.strong_rand_bytes(1) do
      <<b>> when b < @rejection_ceiling -> Enum.at(@user_code_alphabet, rem(b, @alphabet_size))
      _ -> random_alphabet_char()
    end
  end

  defp unix_now(opts) do
    case Keyword.get(opts, :now) do
      nil -> System.system_time(:second)
      n when is_integer(n) -> n
      %DateTime{} = dt -> DateTime.to_unix(dt, :second)
    end
  end
end