if Code.ensure_loaded?(Plug.Conn) do
defmodule AttestoPhoenix.OAuthError do
@moduledoc """
The error value type and the wire-rendering helpers for the
authorization-server controllers and the protected-resource plugs.
This module is both:
* **a struct** - the controllers build `%AttestoPhoenix.OAuthError{}`
with `new/2` / `new/3` and thread it through their `with` chains as
the `{:error, error}` term, then render it once at the boundary with
`render/2`; and
* **a set of header helpers** - `unauthorized/4`, `use_dpop_nonce/3`,
`insufficient_scope/3`, `no_store/2`, and `www_authenticate/3` -
used by the protected-resource plugs to emit `WWW-Authenticate`
challenges and cache-suppression headers directly.
It is the single place the library turns an internal error into the bytes
a client receives. It covers four surfaces, each governed by a different
RFC:
* **Token / endpoint errors** (RFC 6749 §5.2) - the JSON body
`{"error": <code>, "error_description": <text>}` returned by the
token, revocation, and registration endpoints. When the request
attempted HTTP `Authorization`-based client authentication and the
status is 401, RFC 6749 §5.2 requires a matching `WWW-Authenticate`
challenge, so `render/2` re-derives it from the request rather than
trusting the caller to remember.
* **Protected-resource challenges** (RFC 6750 §3 / RFC 9449 §7.1) - a
`WWW-Authenticate` response header naming the `Bearer` or `DPoP`
scheme and carrying the `error`, `error_description`, `scope`, and
(for DPoP) `algs` auth-params.
* **DPoP nonce challenges** (RFC 9449 §8 / §9) - the `use_dpop_nonce`
error returned with a fresh `DPoP-Nonce` response header, telling
the client to retry the request carrying that nonce.
* **Cache suppression** (RFC 6749 §5.1) - `no_store/2` marks a
response uncacheable with `Cache-Control: no-store` and
`Pragma: no-cache`, mandatory on every response that carries a
token, and applied to every error response here for defense in depth.
Every quoted auth-param value is escaped per the `WWW-Authenticate`
quoted-string grammar (RFC 9110 §11.2 / RFC 7235): a bare `"` or `\\`
inside a value would otherwise let an attacker break out of the quotes
and inject additional challenge parameters.
## Configuration callbacks
The transport details are policy a host may override. Each is read from
`AttestoPhoenix.Config` and falls back to the RFC-correct default
implemented here when the host does not set it:
* `:send_error` - `(conn, status, body_map -> conn)`. Serializes the
RFC 6749 §5.2 envelope and sends the response. Default encodes JSON
with `application/json` and halts.
* `:no_store` - `(conn -> conn)`. Sets the RFC 6749 §5.1 cache
headers. Default sets `Cache-Control: no-store` and
`Pragma: no-cache`.
* `:www_authenticate` - `(conn, challenge_string -> conn)`. Writes the
challenge header. Default sets the `www-authenticate` response
header.
The RFC semantics (which code maps to which status, which auth-params,
which header) are owned by this module and are not overridable; only the
serialization/transport is.
This module compiles only when `Plug` is available.
"""
import Plug.Conn
alias AttestoPhoenix.Callback
alias AttestoPhoenix.Config
@typedoc "The protected-resource authentication scheme a challenge names."
@type scheme :: :bearer | :dpop
@typedoc "An OAuth 2.0 error value rendered to the RFC 6749 §5.2 envelope."
@type t :: %__MODULE__{
error: atom(),
error_description: String.t() | nil,
status: pos_integer(),
headers: [{String.t(), String.t()}]
}
@enforce_keys [:error, :status]
defstruct [:error, :error_description, :status, headers: []]
# RFC 6749 §5.1: token responses MUST NOT be cached by any intermediary.
@cache_control_no_store "no-store"
@pragma_no_cache "no-cache"
# RFC 9449 §8 / §9: the error code that asks a client to retry carrying a
# server-issued nonce, paired with the `DPoP-Nonce` response header.
@use_dpop_nonce "use_dpop_nonce"
# RFC 6750 §3.1: the error code returned when a valid token lacks the
# scope the request requires, paired with the `scope` auth-param.
@insufficient_scope "insufficient_scope"
# RFC 6749 §2.3.1 names HTTP Basic as the mandatory-to-support scheme for
# confidential-client authentication at the token endpoint; a 401 from an
# `Authorization`-authenticated request advertises it (RFC 6749 §5.2).
@basic_scheme "Basic"
@default_realm "OAuth"
# RFC 6749 §5.2 default status mapping. `invalid_client` is the only code
# the spec singles out: 401 when client authentication was attempted via
# the `Authorization` header, 400 otherwise. It defaults to 400 here and
# `render/2`'s request inspection raises it to 401 so a code path that
# forgets to set the status still produces a valid envelope. RFC 6750 §3.1
# / RFC 9449 §7.1 protected-resource codes (`invalid_token`,
# `invalid_dpop_proof`, `use_dpop_nonce`) are 401 and `insufficient_scope`
# is 403; those are emitted through the dedicated challenge helpers below.
@default_status %{
invalid_request: 400,
invalid_client: 400,
invalid_grant: 400,
unauthorized_client: 400,
unsupported_grant_type: 400,
invalid_scope: 400,
invalid_dpop_proof: 400,
invalid_token: 401,
use_dpop_nonce: 401,
insufficient_scope: 403,
# RFC 7591 §3.2.2 dynamic-registration error codes.
invalid_client_metadata: 400,
invalid_redirect_uri: 400,
# A library-internal code for "this endpoint is not enabled"; rendered
# like any other so the host never sees a route it did not opt into.
not_found: 404
}
@doc """
Build an OAuth 2.0 error value (RFC 6749 §5.2).
`code` is the error code atom (e.g. `:invalid_request`, `:invalid_client`).
`description` is the human-readable `error_description` (or `nil`). The
HTTP status defaults from the RFC 6749 §5.2 mapping for `code` and can be
overridden with the `:status` option. The `:headers` option carries extra
response headers a caller must emit alongside the error (e.g. the
RFC 9449 §8 `DPoP-Nonce` header on a `use_dpop_nonce` error); it defaults
to `[]`.
"""
@spec new(atom(), String.t() | nil, keyword()) :: t()
def new(code, description \\ nil, opts \\ []) when is_atom(code) do
%__MODULE__{
error: code,
error_description: description,
status: Keyword.get(opts, :status, default_status(code)),
headers: Keyword.get(opts, :headers, [])
}
end
@doc """
Render an `%AttestoPhoenix.OAuthError{}` to the RFC 6749 §5.2 wire format.
Writes the JSON envelope `{"error": code, "error_description": desc}` with
the error's status, applies the RFC 6749 §5.1 no-store headers, and - when
the request attempted `Authorization`-based client authentication and the
status is 401 - adds the RFC 6749 §5.2 `WWW-Authenticate: Basic`
challenge. The Basic realm defaults to `"OAuth"` and may be overridden by
the `:basic_realm` config key.
"""
@spec render(Plug.Conn.t(), t()) :: Plug.Conn.t()
def render(conn, %__MODULE__{} = error) do
config = fetch_config(conn)
status = effective_status(error, conn)
body =
%{"error" => Atom.to_string(error.error)}
|> maybe_put("error_description", error.error_description)
conn
|> no_store(config)
|> maybe_basic_challenge(config, status, basic_realm(config))
|> do_send_error(config, status, body)
end
@doc """
Respond 401 with a protected-resource `WWW-Authenticate` challenge for
`scheme` (RFC 6750 §3 / RFC 9449 §7.1).
The challenge carries `error` (an OAuth error code string) and, when
supplied, the optional auth-params. Options:
* `:description` - the `error_description` auth-param.
* `:scope` - a space-delimited scope string for the `scope` auth-param.
* `:algs` - a space-delimited list of acceptable DPoP signing
algorithms for the RFC 9449 §5.1 `algs` auth-param (`:dpop` scheme).
* `:dpop_nonce` - sets the RFC 9449 §8 `DPoP-Nonce` response header.
Sets the status, the challenge header, any DPoP nonce header, the
RFC 6749 §5.1 no-store headers, and writes the RFC 6749 §5.2 body.
"""
@spec unauthorized(Plug.Conn.t(), scheme(), String.t(), keyword()) :: Plug.Conn.t()
def unauthorized(conn, scheme, error, opts \\ []) when scheme in [:bearer, :dpop] and is_binary(error) do
config = fetch_config(conn)
params =
[{"error", error}]
|> append_param("error_description", Keyword.get(opts, :description))
|> append_param("scope", Keyword.get(opts, :scope))
|> append_param("algs", Keyword.get(opts, :algs))
conn
|> no_store(config)
|> maybe_put_dpop_nonce(Keyword.get(opts, :dpop_nonce))
|> www_authenticate(config, challenge(scheme, params))
|> do_send_error(config, 401, error_body(error, Keyword.get(opts, :description)))
end
@doc """
Respond 401 `#{@use_dpop_nonce}` carrying a fresh `DPoP-Nonce` header
(RFC 9449 §8 / §9).
The protected resource (or token endpoint) uses this to demand the client
retry the request including the server-issued `nonce`. Emits a `DPoP`
challenge whose `error` is `use_dpop_nonce`, sets the `DPoP-Nonce`
response header, and applies the RFC 6749 §5.1 no-store headers.
"""
@spec use_dpop_nonce(Plug.Conn.t(), String.t(), keyword()) :: Plug.Conn.t()
def use_dpop_nonce(conn, nonce, opts \\ []) when is_binary(nonce) do
unauthorized(
conn,
:dpop,
@use_dpop_nonce,
opts
|> Keyword.put(:dpop_nonce, nonce)
|> Keyword.put_new(
:description,
"Authorization server requires a nonce in the DPoP proof."
)
)
end
@doc """
Respond 403 `#{@insufficient_scope}` naming the `required` scopes
(RFC 6750 §3.1).
The `WWW-Authenticate` challenge for `scheme` carries the `error`,
`error_description`, and the RFC 6750 §3.1 `scope` auth-param listing the
scopes the request would need. Applies the RFC 6749 §5.1 no-store headers.
"""
@spec insufficient_scope(Plug.Conn.t(), [String.t()], scheme()) :: Plug.Conn.t()
def insufficient_scope(conn, required, scheme \\ :bearer) when is_list(required) and scheme in [:bearer, :dpop] do
config = fetch_config(conn)
scope = Enum.join(required, " ")
description = "The request requires higher privileges: #{scope}"
params = [
{"error", @insufficient_scope},
{"error_description", description},
{"scope", scope}
]
conn
|> no_store(config)
|> www_authenticate(config, challenge(scheme, params))
|> do_send_error(config, 403, error_body(@insufficient_scope, description))
end
@doc """
Apply the RFC 6749 §5.1 cache-suppression headers to `conn`.
Sets `Cache-Control: no-store` and `Pragma: no-cache`. Mandatory on every
response that carries an access or refresh token. Delegates to the host's
`:no_store` callback when configured.
"""
@spec no_store(Plug.Conn.t(), Config.t() | nil) :: Plug.Conn.t()
def no_store(conn, config \\ nil) do
case config_callback(config, :no_store) do
nil -> default_no_store(conn)
callback -> Callback.invoke(callback, [conn])
end
end
@doc """
Set the `WWW-Authenticate` response header to `challenge`.
Delegates to the host's `:www_authenticate` callback when configured;
otherwise sets the `www-authenticate` header directly.
"""
@spec www_authenticate(Plug.Conn.t(), Config.t() | nil, String.t()) :: Plug.Conn.t()
def www_authenticate(conn, config \\ nil, challenge) when is_binary(challenge) do
case config_callback(config, :www_authenticate) do
nil -> put_resp_header(conn, "www-authenticate", challenge)
callback -> Callback.invoke(callback, [conn, challenge])
end
end
# ----- internal -----
defp default_status(code), do: Map.get(@default_status, code, 400)
# RFC 6749 §5.2: `invalid_client` is 401 when the request authenticated
# via the `Authorization` header. The struct defaults it to 400; raise it
# to 401 here when an `Authorization` attempt is present so the dedicated
# Basic challenge can attach.
defp effective_status(%__MODULE__{error: :invalid_client, status: 400}, conn) do
if authorization_attempted?(conn), do: 401, else: 400
end
defp effective_status(%__MODULE__{status: status}, _conn), do: status
# RFC 6749 §5.2: a 401 returned to a request that attempted
# `Authorization`-header client authentication MUST carry a matching
# `WWW-Authenticate` challenge. It is re-derived from the request so any
# caller that returns 401 is compliant without remembering the header.
defp maybe_basic_challenge(conn, config, 401, realm) do
if authorization_attempted?(conn) do
www_authenticate(conn, config, basic_challenge(realm))
else
conn
end
end
defp maybe_basic_challenge(conn, _config, _status, _realm), do: conn
defp authorization_attempted?(conn) do
get_req_header(conn, "authorization") != []
end
defp basic_challenge(realm) do
@basic_scheme <> ~s( realm="#{escape(realm)}")
end
defp basic_realm(config) when is_map(config), do: Map.get(config, :basic_realm) || @default_realm
defp basic_realm(_config), do: @default_realm
# The config is threaded onto the conn by the router/pipeline; when it is
# absent (e.g. a plug emitting a challenge before the config is assigned)
# the RFC-correct default transport is used.
defp fetch_config(conn) do
case conn.private do
%{attesto_phoenix_config: %Config{} = config} -> config
_ -> nil
end
end
defp do_send_error(conn, config, status, body) do
case config_callback(config, :send_error) do
nil -> default_send_error(conn, status, body)
callback -> Callback.invoke(callback, [conn, status, body])
end
end
# The transport callbacks (`:send_error`, `:no_store`, `:www_authenticate`)
# are read defensively: a host that supplies them overrides the default
# transport, while a config (or a `nil` config, before the pipeline has
# assigned one) that omits them falls through to the RFC-correct default.
defp config_callback(%Config{} = config, key), do: Callback.config_callback(config, key)
defp config_callback(_config, _key), do: nil
defp default_send_error(conn, status, body) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, JSON.encode!(body))
|> halt()
end
defp default_no_store(conn) do
conn
|> put_resp_header("cache-control", @cache_control_no_store)
|> put_resp_header("pragma", @pragma_no_cache)
end
# RFC 9110 §11.1: `WWW-Authenticate` is `scheme SP #auth-param`. The
# auth-param values are quoted-strings.
defp challenge(scheme, params) do
label = scheme_label(scheme)
param_str = Enum.map_join(params, ", ", fn {k, v} -> ~s(#{k}="#{escape(v)}") end)
label <> " " <> param_str
end
defp scheme_label(:dpop), do: "DPoP"
defp scheme_label(:bearer), do: "Bearer"
defp error_body(error, nil), do: %{"error" => error}
defp error_body(error, description), do: %{"error" => error, "error_description" => description}
defp append_param(params, _key, nil), do: params
defp append_param(params, key, value), do: params ++ [{key, value}]
defp maybe_put_dpop_nonce(conn, nil), do: conn
defp maybe_put_dpop_nonce(conn, nonce), do: put_resp_header(conn, "dpop-nonce", nonce)
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
# `WWW-Authenticate` auth-param values are quoted-strings (RFC 9110
# §11.2 / RFC 7235); escape the two characters that would otherwise let a
# value break out of the surrounding quotes and inject new auth-params.
defp escape(value) do
value
|> to_string()
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
end
end