Packages
attesto_phoenix
0.7.2
2.1.0
2.0.2
2.0.1
2.0.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.20.0
0.19.1
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer over attesto: authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource plugs, and Ecto-backed token stores.
Current section
Files
Jump to
Current section
Files
lib/attesto_phoenix/oauth_error.ex
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.
"""
alias AttestoPhoenix.Callback
alias AttestoPhoenix.Config
import Plug.Conn
@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