Packages
attesto_phoenix
0.19.0
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/controller/token_controller.ex
defmodule AttestoPhoenix.Controller.TokenController do
@moduledoc """
OAuth 2.0 token endpoint (RFC 6749 §3.2).
Handles `POST /oauth/token`. This module owns the HTTP and protocol-framing
concerns only: it resolves the host `%AttestoPhoenix.Config{}`, applies the
no-store cache headers (RFC 7234 §5.2), authenticates the client (RFC 6749
§2.3), lifts the request and the relevant conn facts into a plain
`AttestoPhoenix.AuthorizationServer.Token.Request`, calls the conn-free core
`AttestoPhoenix.AuthorizationServer.Token.issue/2`, emits the audit events the
core returns as data, and renders the RFC 6749 §5.1 success body or the
RFC 6749 §5.2 error body. It carries no business-domain logic; every grant,
claim, and policy decision lives in the core or behind a
`AttestoPhoenix.Config` callback.
## Grant types
* `authorization_code` (RFC 6749 §4.1.3) with mandatory PKCE (RFC 7636).
* `refresh_token` (RFC 6749 §6) with rotation and reuse detection
(RFC 6749 §10.4, OAuth 2.0 Security BCP).
* `client_credentials` (RFC 6749 §4.4).
* OAuth token exchange (RFC 8693).
## Client authentication
Accepts HTTP Basic credentials (RFC 6749 §2.3.1, RFC 7617), request-body
credentials (RFC 6749 §2.3.1), and `private_key_jwt` assertions (RFC 7523 /
OIDC Core §9). Presenting more than one client-authentication method is
rejected (RFC 6749 §2.3). Confidential clients must authenticate; a client
identified without a secret/assertion is admitted only when the host's
`:client_public?` callback marks it public, in which case it relies on PKCE
(RFC 7636) instead.
## Responses
Success renders the RFC 6749 §5.1 body; failure renders the RFC 6749 §5.2
error body. Both carry no-store cache headers (RFC 7234 §5.2) so credentials
are never cached by an intermediary. A `use_dpop_nonce` error carries the
fresh `DPoP-Nonce` header (RFC 9449 §8) verbatim in its `:headers`.
## Configuration
All host policy is resolved through `AttestoPhoenix.Config`; nothing is
hardcoded here. See `AttestoPhoenix.AuthorizationServer.Token` for the
grant/claim policy callbacks the core reads, and `AttestoPhoenix.Config` for
the authoritative definitions and defaults.
"""
use Phoenix.Controller, formats: [:json]
import Plug.Conn
alias AttestoPhoenix.AuthorizationServer.SenderConstraint
alias AttestoPhoenix.AuthorizationServer.Token
alias AttestoPhoenix.AuthorizationServer.Token.Request
alias AttestoPhoenix.{Callback, ClientAuthentication, Config, Event, OAuthError, RequestContext}
alias AttestoPhoenix.ClientAuthentication.{ErrorContext, Policy}
alias Plug.Conn.Unfetched
require Logger
# RFC 7234 §5.2: token responses and errors must never be cached.
@cache_control_no_store "no-store"
@pragma_no_cache "no-cache"
# RFC 6749 §5.2 / RFC 9449 §5 error codes the framing layer raises before the
# core runs, held as the atoms `OAuthError.new/3` requires (no string round-trip).
@error_invalid_request :invalid_request
@error_invalid_dpop_proof :invalid_dpop_proof
# RFC 6749 §2.3.1 / §4.4.2 place token request credentials and requested
# scope in the form body. Query-string credentials leak through access logs
# and caches, so reject them before client authentication or grant dispatch.
@query_credential_params ~w(grant_type client_id client_secret scope)
@accepted_content_types ~w(application/x-www-form-urlencoded application/json)
# RFC 9449 §4.1: the DPoP proof request header read off the conn and passed
# to the core as data.
@dpop_request_header "dpop"
# RFC 9449 §4.2: the token endpoint is reached by POST, so the proof's `htm`
# claim must equal this.
@http_method_post "POST"
# RFC 7523 / OIDC Core §9: client assertions are short-lived JWTs whose `jti`
# is consumed once by the authorization server.
@client_assertion_max_lifetime 300
@doc """
Token endpoint action (RFC 6749 §3.2).
Authenticates the client, delegates the grant to the core, emits the returned
audit events, and renders either the RFC 6749 §5.1 success body or an
RFC 6749 §5.2 error. Every response carries no-store cache headers
(RFC 7234 §5.2).
"""
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do
config = resolve_config()
conn = put_no_store_headers(conn)
with :ok <- require_token_content_type(conn),
:ok <- reject_query_credentials(conn),
:ok <- reject_multiple_dpop_proofs(conn) do
create_transport_checked(config, conn, params)
else
{:error, %OAuthError{} = err} ->
deny(config, conn, request_body_params(conn, params), nil, err)
end
end
defp require_token_content_type(conn) do
case token_content_type(conn) do
content_type when content_type in @accepted_content_types ->
:ok
nil ->
{:error, error(@error_invalid_request, "missing Content-Type")}
content_type ->
{:error, error(@error_invalid_request, "unsupported token request Content-Type: #{content_type}")}
end
end
defp token_content_type(conn) do
case get_req_header(conn, "content-type") do
[content_type | _] ->
content_type
|> String.split(";", parts: 2)
|> hd()
|> String.trim()
|> String.downcase()
[] ->
nil
end
end
defp reject_query_credentials(conn) do
case Enum.find(@query_credential_params, &Map.has_key?(query_params(conn), &1)) do
nil ->
:ok
key ->
{:error, error(@error_invalid_request, "#{key} must be sent in the request body, not the query string")}
end
end
# RFC 9449 §4.3: a token request carries at most one DPoP proof JWT. More than
# one `DPoP` header field is rejected outright rather than processed - the
# server must not silently pick one proof when several are presented (a header
# smuggled past an intermediary could otherwise select an attacker's proof).
defp reject_multiple_dpop_proofs(conn) do
case get_req_header(conn, @dpop_request_header) do
[_, _ | _] ->
{:error, error(@error_invalid_dpop_proof, "Multiple DPoP proof JWTs in request")}
_ ->
:ok
end
end
defp create_transport_checked(config, conn, params) do
case RequestContext.check_https(conn, config) do
:ok ->
create_checked(config, conn, params)
{:error, :insecure_transport} ->
# RFC 6749 §3.2 / §10.1: the token endpoint requires TLS.
deny(config, conn, params, nil, error(@error_invalid_request, "TLS required"))
end
end
defp query_params(%Plug.Conn{query_params: %Unfetched{}} = conn) do
conn
|> fetch_query_params()
|> Map.fetch!(:query_params)
end
defp query_params(%Plug.Conn{query_params: params}) when is_map(params), do: params
# An unparsed body (e.g. a denied unsupported Content-Type) leaves
# `body_params` an `%Unfetched{}` struct. A struct IS a map, so the
# `is_map/1` clause below would let it through and a later key access would
# raise `Unfetched`; fall back to the action params instead.
defp request_body_params(%Plug.Conn{body_params: %Unfetched{}}, fallback), do: fallback
defp request_body_params(%Plug.Conn{body_params: params}, _fallback) when is_map(params), do: params
defp create_checked(config, conn, params) do
case authenticate_client(config, conn, params) do
{:ok, client, method} ->
create_authenticated(config, conn, params, client, method)
{:error, %OAuthError{} = err} ->
# A holder-of-key (DPoP) failure on a sender-constrained code takes
# precedence over the client-auth error; otherwise fall back to it.
deny(config, conn, params, nil, holder_of_key_error(config, conn, params) || err)
end
end
# RFC 9449 §10 / FAPI2 "ensure holder-of-key required": a sender-constrained
# (DPoP-bound) authorization code redeemed WITHOUT a DPoP proof is a
# holder-of-key failure. FAPI2 expects it reported as
# invalid_request/invalid_grant/invalid_dpop_proof - and it must surface even
# when the request ALSO lacks client authentication (the suite's test sends
# neither), where the client-auth check would otherwise mask it with
# invalid_client. The code is READ (`get/1`), never consumed, so a legitimate
# retry is unaffected. Only DPoP-bound codes match; a plain (e.g. OIDC) code
# still surfaces the client-auth error. Returns `nil` when it does not apply.
defp holder_of_key_error(config, conn, %{"grant_type" => "authorization_code", "code" => code})
when is_binary(code) do
store = Callback.config_callback(config, :code_store)
if is_atom(store) and not is_nil(store) and is_nil(first_dpop_proof(conn)) and
Attesto.AuthorizationCode.dpop_bound?(store, code) do
error(@error_invalid_request, "DPoP proof required for a sender-constrained authorization code")
end
end
defp holder_of_key_error(_config, _conn, _params), do: nil
defp create_authenticated(config, conn, params, client, method) do
case fetch_grant_type(params) do
{:ok, grant_type} ->
issue(config, conn, params, client, method, grant_type)
{:error, %OAuthError{} = err} ->
# A valid client without a grant_type: the core never runs, so the
# framing layer emits the denial event (the resolved client's id is
# known, but no grant_type was parsed).
deny(config, conn, params, client, err)
end
end
defp issue(config, conn, params, client, method, grant_type) do
request = build_request(config, conn, params, client, method, grant_type)
case Token.issue(config, request) do
{:ok, response, events} ->
emit_all(config, events)
conn
|> put_status(:ok)
|> json(response)
{:error, %OAuthError{} = err, events} ->
emit_all(config, events)
render_error(conn, err)
end
end
# ── Request building (conn -> data) ──────────────────────────────────────
# Lift the request and the conn facts the core needs into a plain
# `Token.Request`. The sender-constraint input (RFC 9449 / RFC 8705) is parsed
# off the conn here; the core reads only this data. `client_ip` and the
# request-derived `client_id` are carried for the audit events the core builds.
defp build_request(config, conn, params, client, method, grant_type) do
%Request{
config: config,
client: client,
client_auth_method: method,
grant_type: grant_type,
params: params,
sender_constraint_input: sender_constraint_input(config, conn),
client_ip: RequestContext.client_ip(conn, config),
request_client_id: request_client_id(conn, params)
}
end
defp sender_constraint_input(config, conn) do
%{
dpop_proof: first_dpop_proof(conn),
mtls_cert_der: RequestContext.cert_der(conn, config),
http_uri: RequestContext.canonical_url(conn, config),
http_method: @http_method_post
}
end
defp first_dpop_proof(conn) do
case get_req_header(conn, @dpop_request_header) do
[proof | _] -> proof
_ -> nil
end
end
# ── Configuration resolution ─────────────────────────────────────────────
# The validated `%AttestoPhoenix.Config{}` is resolved from the host's
# `:otp_app` configuration so the controller holds no policy of its own.
defp resolve_config do
otp_app = Application.get_env(:attesto_phoenix, :otp_app)
Config.from_otp_app(otp_app, Config)
end
# ── Client authentication (RFC 6749 §2.3) ────────────────────────────────
# RFC 6749 §2.3: client authentication is delegated to the conn-free core
# `AttestoPhoenix.ClientAuthentication`, shared with the PAR endpoint. The
# token endpoint's policy: a body `client_id` without a secret is the
# public-client path (RFC 6749 §2.1), so `allow_public: true`; the client
# assertion MUST be audienced to the issuer identifier (FAPI 2.0 Security
# Profile §5.3.2.1), derived from trusted `Config` (never the request `Host`) -
# the concrete endpoint URL is NOT accepted as `aud`, so a confused-deputy
# assertion minted for a different endpoint is rejected. The assertion lives at
# most `@client_assertion_max_lifetime` seconds (RFC 7523 §3).
defp authenticate_client(config, conn, params) do
policy = %Policy{
allow_public: true,
assertion_audiences: [config.issuer],
assertion_max_lifetime: @client_assertion_max_lifetime,
assertion_signing_algs: config.client_auth_signing_algs
}
case ClientAuthentication.authenticate_with_context(
get_req_header(conn, "authorization"),
params,
config,
policy
) do
{:ok, %ClientAuthentication.Result{client: client, method: method}} ->
{:ok, client, method}
{:error, %OAuthError{} = err, %ErrorContext{} = context} ->
{:error, token_endpoint_client_auth_error(config, err, context)}
end
end
# RFC 6749 §5.2: `invalid_client` caused by an `Authorization`-header client
# authentication attempt is rendered as 401 with a matching challenge. Body
# credentials and absent credentials stay the ordinary 400 `invalid_client`.
defp token_endpoint_client_auth_error(config, %OAuthError{error: :invalid_client} = err, %ErrorContext{
authorization_scheme: scheme
})
when is_binary(scheme) do
%{
err
| status: 401,
headers: put_www_authenticate(err.headers, client_auth_challenge(config, scheme))
}
end
defp token_endpoint_client_auth_error(_config, %OAuthError{} = err, _context), do: err
defp put_www_authenticate(headers, challenge) do
headers
|> Enum.reject(fn {key, _value} -> String.downcase(key) == "www-authenticate" end)
|> Kernel.++([{"www-authenticate", challenge}])
end
defp client_auth_challenge(%Config{basic_realm: realm}, scheme) do
# `:basic_realm` is typed `String.t()` and defaults to "OAuth", so it is
# always a binary - no nil fallback is needed (and dialyzer flags one as dead).
challenge_scheme(scheme) <> ~s( realm="#{escape_auth_param(realm)}")
end
defp challenge_scheme(scheme) do
if String.downcase(scheme) == "basic", do: "Basic", else: scheme
end
defp escape_auth_param(value) do
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
defp fetch_grant_type(%{"grant_type" => gt}) when is_binary(gt) and gt != "", do: {:ok, gt}
defp fetch_grant_type(_params), do: {:error, error(@error_invalid_request, "missing grant_type")}
# ── Audit / telemetry ────────────────────────────────────────────────────
# The core returns audit events as data; the controller emits them. A
# framing-layer denial (TLS, client auth, missing grant_type) never reaches
# the core, so the controller builds that one denial event itself.
defp emit_all(config, events) do
Enum.each(events, &Event.dispatch(Config.on_event_fun(config), &1))
end
# Build and emit the RFC 6749 §5.2 denial event for a framing-layer failure,
# then render the error. Mirrors the core's denial event for the fields a
# pre-core failure can know: there is no resolved grant_type for a missing-
# grant or client-auth failure, and the client may be unauthenticated.
defp deny(config, conn, params, client, %OAuthError{} = err) do
code = Atom.to_string(err.error)
client_id = denial_client_id(config, conn, params, client)
Event.emit(config, :token_denied, %{
client_id: client_id,
scope: optional_param(params, "scope"),
grant_type: optional_param(params, "grant_type"),
result: code,
metadata:
%{
client_ip: RequestContext.client_ip(conn, config),
client_id: client_id,
reason: err.error,
error: code,
error_description: err.error_description,
http_status: err.status
}
|> Map.merge(sender_constraint_audit_metadata(config, conn))
|> Enum.reject(fn {key, value} -> key != :cnf and is_nil(value) end)
|> Map.new()
})
render_error(conn, err)
end
defp denial_client_id(config, conn, params, client) when not is_nil(client) do
client_id(config, client) || request_client_id(conn, params)
end
defp denial_client_id(_config, conn, params, _client) do
request_client_id(conn, params)
end
defp client_id(config, client) do
Callback.invoke(Config.client_id_fun(config), [client], nil)
end
defp request_client_id(conn, params), do: optional_param(params, "client_id") || basic_client_id(conn)
defp basic_client_id(conn) do
with ["Basic " <> encoded] <- get_req_header(conn, "authorization"),
{:ok, decoded} <- Base.decode64(encoded),
[client_id, _secret] <- String.split(decoded, ":", parts: 2) do
URI.decode_www_form(client_id)
else
_ -> nil
end
end
defp sender_constraint_audit_metadata(config, conn) do
SenderConstraint.audit_metadata(config, sender_constraint_input(config, conn))
end
# ── Rendering (RFC 6749 §5.2) ────────────────────────────────────────────
defp render_error(conn, %OAuthError{} = err) do
# RFC 6749 §5.2 keeps the wire body terse (a code, an optional description),
# which makes an opaque `invalid_request` / `invalid_scope` 400 hard to
# diagnose from the response alone. Surface the resolved code + description in
# the log at the single render boundary so a host operator can tell e.g.
# `invalid_scope` from `invalid_grant` without reading the source — at
# `:debug`, so a 4xx under load is never prod log noise (the structured
# `:token_denied` event carries the same reason for hosts that want it louder).
Logger.debug(fn ->
"token endpoint denied (#{err.status}): #{err.error}" <>
if(err.error_description, do: " — #{err.error_description}", else: "")
end)
conn
|> merge_resp_headers(err.headers)
|> put_status(err.status)
|> json(error_body(err.error, err.error_description))
end
# RFC 6749 §5.2 error response body.
defp error_body(code, nil), do: %{error: code}
defp error_body(code, description), do: %{error: code, error_description: description}
# The single error value the controller raises at the framing edge is an
# `%AttestoPhoenix.OAuthError{}` (the shape the core and the
# `ClientAuthentication` core also return). `code` is a compile-time
# RFC 6749 §5.2 error-code atom passed straight to `OAuthError.new/3`.
defp error(code, description), do: OAuthError.new(code, description, status: 400)
defp optional_param(params, key) do
case params[key] do
value when is_binary(value) and value != "" -> value
_ -> nil
end
end
defp put_no_store_headers(conn) do
conn
|> put_resp_header("cache-control", @cache_control_no_store)
|> put_resp_header("pragma", @pragma_no_cache)
end
end