Current section

Files

Jump to
attesto_phoenix lib attesto_phoenix controller device_verification_controller.ex
Raw

lib/attesto_phoenix/controller/device_verification_controller.ex

defmodule AttestoPhoenix.Controller.DeviceVerificationController do
@moduledoc """
Device verification page (RFC 8628 §3.3).
The user-facing leg of the device grant: the user opens
`GET /oauth/device_verification` (optionally with `?user_code=...` from the
`verification_uri_complete`), authenticates with the host, confirms the
`user_code`, and approves or denies. This controller owns the protocol — it
resolves and normalizes the `user_code`, drives the host login, and performs
the atomic store transition (`Attesto.DeviceCode.approve/3` / `deny/2`) — while
the host owns the HTML and the login UI through two callbacks:
* `:authenticate_device_user` — `(conn -> {:ok, subject} | {:halt, conn})`.
Establishes the resource owner (the host's session/login). `subject` is a
map with `:subject` (the `sub`) and optional `:scope` / `:claims`
(carrying e.g. `acr`/`auth_time` for step-up). `{:halt, conn}` lets the
host take over the connection to render its login UI.
* `:render_device_verification` — `(conn, view -> conn)`. Renders the page.
`view` is a map `%{stage, user_code, pending}` where `stage` is
`:prompt` (ask the user to enter/confirm the code — `pending` is the
`Attesto.DeviceCodeStore.pending_view()` or `nil`), `:approved`,
`:denied`, or `:invalid` (unknown/expired/already-decided code).
## No auto-approval (RFC 8628 §3.3.1 / §5.4)
A `user_code` arriving via `verification_uri_complete` is only ever
pre-filled — approval requires an explicit user POST carrying
`decision=approve`. The controller never approves from a GET or from the URL
alone, closing the one-click remote-phishing vector.
## Host responsibility: CSRF + session (REQUIRED)
The approve/deny POST is a state-changing, session-authenticated action. The
library performs the store transition but does NOT own the browser session or
CSRF token, so the host MUST mount these routes behind a pipeline that
enforces CSRF protection (`protect_from_forgery`) and a same-site session —
otherwise a logged-in victim's browser can be made to POST an attacker's
`user_code` and approve the attacker's device (a confused-deputy / device
login CSRF). The controller additionally requires HTTPS (a credential-bearing
authorization action must not cross a plain-HTTP hop).
"""
use Phoenix.Controller, formats: [:html, :json]
alias Attesto.DeviceCode
alias AttestoPhoenix.{Callback, Config, RequestContext}
@spec verify(Plug.Conn.t(), map()) :: Plug.Conn.t()
def verify(conn, params) do
config = resolve_config()
with :ok <- require_enabled(config),
:ok <- check_https(conn, config),
{:ok, store} <- require_store(config),
{:ok, conn, subject} <- authenticate(conn, config) do
handle(conn, config, store, subject, params)
else
{:halt, conn} -> conn
{:error, status, body} -> conn |> put_status(status) |> json(body)
end
end
defp check_https(conn, config) do
case RequestContext.check_https(conn, config) do
:ok -> :ok
{:error, :insecure_transport} -> {:error, 400, %{error: "invalid_request", error_description: "TLS required"}}
end
end
# GET (or POST without a decision) shows the confirm prompt; a POST carrying an
# explicit `decision` performs the approve/deny store transition. The user_code
# is normalized in the core before any store lookup (fail-closed).
defp handle(conn, config, store, subject, params) do
user_code = string_param(params["user_code"])
decision = string_param(params["decision"])
case {conn.method, decision} do
{"POST", "approve"} -> approve(conn, config, store, subject, user_code)
{"POST", "deny"} -> deny(conn, config, store, user_code)
_ -> prompt(conn, config, store, user_code)
end
end
defp approve(conn, config, store, subject, user_code) do
approval = %{
subject: Map.get(subject, :subject) || Map.get(subject, :sub),
# The granted scope is the subject's narrowed scope when the host
# login/consent layer supplied one, otherwise the originally requested
# scope bound to the device code — never broader than what was requested.
scope: Map.get(subject, :scope) || pending_scope(store, user_code),
claims: Map.get(subject, :claims, %{})
}
case DeviceCode.approve(store, user_code, approval) do
:ok -> render_stage(conn, config, :approved, user_code, nil)
_error -> render_stage(conn, config, :invalid, user_code, nil)
end
end
defp deny(conn, config, store, user_code) do
case DeviceCode.deny(store, user_code) do
:ok -> render_stage(conn, config, :denied, user_code, nil)
_error -> render_stage(conn, config, :invalid, user_code, nil)
end
end
# No decision yet: show the confirm screen with the pending request the user is
# about to authorize (or just the code-entry prompt when no/invalid code).
defp prompt(conn, config, _store, nil), do: render_stage(conn, config, :prompt, nil, nil)
defp prompt(conn, config, store, user_code) do
case DeviceCode.lookup(store, user_code) do
{:ok, %{status: :pending} = view} -> render_stage(conn, config, :prompt, user_code, view)
{:ok, _decided} -> render_stage(conn, config, :invalid, user_code, nil)
_ -> render_stage(conn, config, :invalid, user_code, nil)
end
end
defp pending_scope(store, user_code) do
case DeviceCode.lookup(store, user_code) do
{:ok, %{scope: scope}} -> scope
_ -> []
end
end
defp authenticate(conn, config) do
case config.authenticate_device_user do
nil ->
{:error, 500, %{error: "server_error", error_description: "device verification login is not configured"}}
callback ->
case Callback.invoke(callback, [conn]) do
{:ok, subject} when is_map(subject) -> {:ok, conn, subject}
{:halt, halted} -> {:halt, halted}
_ -> {:error, 500, %{error: "server_error"}}
end
end
end
defp render_stage(conn, config, stage, user_code, pending) do
view = %{stage: stage, user_code: user_code, pending: pending}
case config.render_device_verification do
nil -> conn |> put_status(200) |> json(default_body(view))
callback -> Callback.invoke(callback, [conn, view])
end
end
# When no host renderer is wired, fall back to a minimal JSON body so the
# endpoint is still functional in tests / API-only deployments.
defp default_body(%{stage: stage, user_code: user_code}), do: %{stage: stage, user_code: user_code}
defp require_enabled(config) do
if Config.device_authorization_enabled?(config),
do: :ok,
else: {:error, 404, %{error: "not_found"}}
end
defp require_store(config) do
case Config.device_code_store(config) do
store when is_atom(store) and not is_nil(store) -> {:ok, store}
_ -> {:error, 500, %{error: "server_error", error_description: "device authorization is not configured"}}
end
end
defp string_param(value) when is_binary(value) and value != "", do: value
defp string_param(_value), do: nil
defp resolve_config do
otp_app = Application.get_env(:attesto_phoenix, :otp_app)
Config.from_otp_app(otp_app, Config)
end
end