Packages
attesto_phoenix
2.3.0
2.3.0
2.2.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/client_authentication.ex
defmodule AttestoPhoenix.ClientAuthentication do
@moduledoc """
OAuth 2.0 client authentication (RFC 6749 §2.3), as conn-free core.
This is the single place that turns the request's `Authorization` header and
body parameters into either an authenticated client or an
`AttestoPhoenix.OAuthError`. It is shared by the token endpoint
(RFC 6749 §3.2) and the Pushed Authorization Request endpoint (RFC 9126):
both authenticate the client identically; only the policy around the
secretless/public path and the event/wire rendering differ, and those are
the caller's concern.
## Methods
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).
## Native apps (RFC 8252 §8.4)
A client the host marks native (`:client_native?`) may only authenticate with
`none`. A native app cannot keep a credential confidential - its binary is on
the end user's device - so presenting a client secret or an assertion is
refused with the same generic `invalid_client` message as any other failure.
The one exception is the one §8.4 makes: a native client the host
*explicitly* classifies as confidential (`:client_public?` returning `false`)
is taken to hold per-instance credentials from dynamic registration, and keeps
the secret path. An absent `:client_public?` callback is not that
classification and resolves to public, so a host that wires `:client_native?`
alone still gets §8.4 enforcement rather than silently accepting a shipped
secret.
`:client_native?` itself defaults to `false`, so none of this affects a host
that has not classified its clients.
## Client ID Metadata Documents (CIMD)
When CIMD (`draft-ietf-oauth-client-id-metadata-document-01`) is enabled and
the presented `client_id` is a CIMD URL, the client is dereferenced from that
URL (`AttestoPhoenix.ClientIdMetadata`) rather than looked up in the host
registry. A CIMD client carries no shared symmetric secret (the document
validation strips `client_secret_*` and the symmetric auth methods), so it can
only authenticate as a **public client** (`none` + PKCE) or with
**`private_key_jwt`** keyed by the document's `jwks` / `jwks_uri`. The Basic /
body-secret paths therefore never resolve a CIMD client: a `client_secret`
presented for a CIMD `client_id` finds no secret to verify and fails with the
generic `invalid_client` message like any other failed authentication. CIMD
resolution is consulted only on the secretless (`none`) and `private_key_jwt`
paths, where the host registry would not hold the URL.
## Policy
The one decision that differs between callers is carried as data on
`AttestoPhoenix.ClientAuthentication.Policy`:
* `:allow_public` - whether a client identified without a secret/assertion
may authenticate as a public client (RFC 6749 §2.1), relying on PKCE
(RFC 7636) downstream. The token endpoint allows this; the PAR endpoint
does not, because a request reference established without proof of
possession of the client secret would let anyone who knows a
confidential client's `client_id` push requests in its name. When
`false`, a body `client_id` without a secret is rejected with
`invalid_client` "client authentication required".
* `:assertion_audiences` - the acceptable `aud` values for a
`private_key_jwt` assertion (RFC 7523 §3: the authorization server
identifier, commonly the issuer or token endpoint URL).
* `:assertion_max_lifetime` - the maximum assertion lifetime, in seconds,
and the replay-record TTL (RFC 7523 §3).
* `:assertion_signing_algs` - the trusted JOSE algorithm allowlist for the
assertion.
* `:assertion_enforce_fapi_alg_policy` - whether trusted assertion keys must
also satisfy FAPI's RSA modulus and Edwards-curve restrictions. `nil`
preserves the explicit-algorithm behavior of older policy structs.
## Return value
`authenticate/4` returns `{:ok, %Result{client, client_id, method}}` or
`{:error, %AttestoPhoenix.OAuthError{}}`. `authenticate_with_context/4`
keeps the same successful return and adds transport context to errors for
endpoints that must distinguish `Authorization`-header authentication attempts
while rendering. Both functions read only data: the
`Authorization` header values and the parsed body params. It never touches a
conn and never emits an event - the caller renders the result/error and
emits whatever audit event it owns.
## Security details preserved
* On an unknown/revoked client, a dummy `verify_client_secret/2` call
against `:unknown_client` runs so the lookup-failure path matches the
wrong-secret path in observable timing (RFC 6749 §2.3 / OWASP).
* Every client-authentication failure returns the single generic
`invalid_client` "client authentication failed" message, so an attacker
cannot tell an unknown client from a wrong secret.
* Presenting more than one authentication method is rejected with
`invalid_request` (RFC 6749 §2.3).
"""
alias Attesto.ClientAssertion
alias Attesto.DPoP.ReplayCache
alias AttestoPhoenix.{Callback, ClientIdMetadata, Config, OAuthError}
defmodule Policy do
@moduledoc """
The per-caller policy for `AttestoPhoenix.ClientAuthentication`.
See the parent module for the meaning of each field. Expressed as data so
a caller passes its policy rather than toggling a behaviour flag inside the
core.
"""
@type t :: %__MODULE__{
allow_public: boolean(),
assertion_audiences: [String.t()],
assertion_max_lifetime: pos_integer(),
assertion_signing_algs: [String.t()],
assertion_enforce_fapi_alg_policy: boolean() | nil
}
@enforce_keys [
:allow_public,
:assertion_audiences,
:assertion_max_lifetime,
:assertion_signing_algs
]
defstruct [
:allow_public,
:assertion_audiences,
:assertion_max_lifetime,
:assertion_signing_algs,
:assertion_enforce_fapi_alg_policy
]
end
defmodule Result do
@moduledoc """
The authenticated client and how it authenticated.
`:client` is the opaque host client value returned by `:load_client`,
`:client_id` is the OAuth identifier (RFC 6749 §2.2) carried by the
credentials (the Basic/body `client_id` or the assertion `sub`). When the
host's optional `:client_id` callback supplies an identifier, it must agree
exactly with the credential-carried value. Library-produced successful
results therefore always contain a non-empty `client_id`; the field remains
optional on the public struct for source compatibility. `:method` is the
RFC 6749 §2.3 / OIDC Core §9 authentication method
(`:client_secret_basic`, `:client_secret_post`, `:private_key_jwt`, or
`:none` for the public-client path).
"""
@type method :: :client_secret_basic | :client_secret_post | :private_key_jwt | :none
@type t :: %__MODULE__{
client: term(),
client_id: String.t() | nil,
method: method()
}
@enforce_keys [:client, :method]
defstruct [:client, :client_id, :method]
end
defmodule ErrorContext do
@moduledoc """
Transport facts known while classifying client authentication.
`:authorization_scheme` is the request-header authentication scheme the
client attempted, `"Basic"` for a present but unusable scheme token, or
`nil` when no `Authorization` header was present. It is intentionally
detached from the error code: callers decide whether a particular OAuth
error should be rendered with a challenge.
"""
@type t :: %__MODULE__{
authorization_scheme: String.t() | nil
}
defstruct [:authorization_scheme]
end
# RFC 6749 §5.2 error codes.
@error_invalid_request "invalid_request"
@error_invalid_client "invalid_client"
@auth_scheme_re ~r/\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
# Generic, non-revealing message for any failure on the client
# authentication path (RFC 6749 §2.3): an attacker must not be able to tell
# an unknown client from a wrong secret.
@client_auth_failed "client authentication failed"
@doc """
Authenticate the client from the request's `Authorization` header values and
body params (RFC 6749 §2.3).
`authorization_headers` is the list of `Authorization` header values (as
returned by `Plug.Conn.get_req_header(conn, "authorization")`). `params` is
the parsed request body. Returns `{:ok, %Result{}}` or
`{:error, %AttestoPhoenix.OAuthError{}}`.
"""
@spec authenticate([String.t()], map(), Config.t(), Policy.t()) ::
{:ok, Result.t()} | {:error, OAuthError.t()}
def authenticate(authorization_headers, params, %Config{} = config, %Policy{} = policy)
when is_list(authorization_headers) and is_map(params) do
case authenticate_with_context(authorization_headers, params, config, policy) do
{:ok, %Result{} = result} -> {:ok, result}
{:error, %OAuthError{} = err, %ErrorContext{}} -> {:error, err}
end
end
@doc """
Authenticates the client and preserves client-authentication transport context
on errors.
The successful return matches `authenticate/4`. Error returns add an
`%ErrorContext{}` naming the `Authorization` scheme when the request attempted
header authentication; token-endpoint callers use it to apply RFC 6749 §5.2
401 challenge rules without re-reading the conn.
"""
@spec authenticate_with_context([String.t()], map(), Config.t(), Policy.t()) ::
{:ok, Result.t()} | {:error, OAuthError.t(), ErrorContext.t()}
def authenticate_with_context(authorization_headers, params, %Config{} = config, %Policy{} = policy)
when is_list(authorization_headers) and is_map(params) do
context = error_context(authorization_headers)
case do_authenticate(authorization_headers, params, config, policy) do
{:ok, %Result{} = result} -> {:ok, result}
{:error, %OAuthError{} = err} -> {:error, err, context}
end
end
defp do_authenticate(authorization_headers, params, config, policy) do
case fetch_client_credentials(authorization_headers, params, policy) do
{:ok, :none, client_id} ->
# RFC 6749 §2.1: identified but unauthenticated. Permitted only for
# public clients, which must compensate with PKCE (RFC 7636).
with :ok <- require_client_auth_method(config, "none") do
load_public_client(config, client_id)
end
{:ok, :client_secret_basic, client_id, secret} ->
with :ok <- require_client_auth_method(config, "client_secret_basic") do
verify_confidential_client(config, client_id, secret, :client_secret_basic)
end
{:ok, :client_secret_post, client_id, secret} ->
with :ok <- require_client_auth_method(config, "client_secret_post") do
verify_confidential_client(config, client_id, secret, :client_secret_post)
end
{:ok, :private_key_jwt, assertion} ->
with :ok <- require_client_auth_method(config, "private_key_jwt") do
verify_private_key_jwt_client(config, policy, assertion)
end
{:error, _} = err ->
err
end
end
defp error_context(headers) do
%ErrorContext{authorization_scheme: authorization_scheme(headers)}
end
defp authorization_scheme([]), do: nil
defp authorization_scheme([header | _]) when is_binary(header) do
scheme =
header
|> String.trim_leading()
|> String.split(~r/\s+/, parts: 2)
|> List.first()
|> valid_authorization_scheme()
scheme || "Basic"
end
defp authorization_scheme([_header | _]), do: "Basic"
defp authorization_scheme(_headers), do: nil
defp valid_authorization_scheme(scheme) when is_binary(scheme) and scheme != "" do
if Regex.match?(@auth_scheme_re, scheme), do: scheme
end
defp valid_authorization_scheme(_scheme), do: nil
# RFC 6749 §2.3: a client MUST NOT use more than one authentication method.
#
# The multiplicity decision turns on a careful reading of RFC 6749 §2.3.1: a
# bare body `client_id` is *identification* (RFC 6749 §2.3.1, "The client
# MAY ... include the client identifier"), not a second authentication
# method. Only a second *credential* (a `client_secret` or a
# `client_assertion`) alongside Basic is the forbidden double authentication
# (RFC 6749 §2.3). When Basic is present its userid is the authoritative
# `client_id`, so a body `client_id` is permitted iff it agrees with it; a
# conflicting body `client_id` is an internally inconsistent request and is
# rejected before any credential is verified.
defp fetch_client_credentials(header, params, policy) do
cond do
assertion_credentials?(params) ->
fetch_assertion_credentials(header, params)
basic_credentials?(header) ->
fetch_basic_credentials(header, params)
header == [] ->
fetch_body_credentials(params, policy)
true ->
{:error, error(@error_invalid_client, "unsupported client authentication scheme")}
end
end
defp assertion_credentials?(%{"client_assertion" => assertion}) when is_binary(assertion) and assertion != "",
do: true
defp assertion_credentials?(_params), do: false
defp basic_credentials?(["Basic " <> _]), do: true
defp basic_credentials?(_header), do: false
defp fetch_assertion_credentials(header, params) do
if header != [] or has_body_secret?(params) do
{:error, error(@error_invalid_request, "multiple client authentication methods")}
else
fetch_private_key_jwt_credentials(
params["client_assertion_type"],
params["client_assertion"]
)
end
end
# RFC 6749 §2.3: a body `client_secret` alongside Basic is two credentials
# and is rejected before any verification. (A body `client_assertion` is
# handled earlier by `fetch_assertion_credentials/2`, which rejects it when
# Basic is present.) Otherwise the Basic userid is the authoritative
# `client_id` (RFC 6749 §2.3.1): a body `client_id` is mere identification,
# permitted only when it agrees with the Basic userid and rejected as an
# internally inconsistent `invalid_request` when it conflicts.
defp fetch_basic_credentials(["Basic " <> encoded], params) do
if has_body_secret?(params) do
{:error, error(@error_invalid_request, "multiple client authentication methods")}
else
reconcile_basic_client_id(decode_basic_credentials(encoded), params)
end
end
defp reconcile_basic_client_id({:ok, :client_secret_basic, basic_id, _secret} = ok, %{"client_id" => body_id})
when is_binary(body_id) and body_id != "" do
if body_id == basic_id do
ok
else
{:error, error(@error_invalid_request, "client_id does not match the Basic credentials")}
end
end
defp reconcile_basic_client_id(decoded, _params), do: decoded
# RFC 6749 §2.1: a client identified by a body `client_id` and a non-empty
# `client_secret` authenticates via `client_secret_post`. A body `client_id`
# without a secret is only the public-client path when the caller's policy
# allows it; otherwise it is a confidential client that failed to
# authenticate.
defp fetch_body_credentials(%{"client_id" => client_id} = params, policy)
when is_binary(client_id) and client_id != "" do
case params["client_secret"] do
secret when is_binary(secret) and secret != "" ->
{:ok, :client_secret_post, client_id, secret}
_ ->
if policy.allow_public do
{:ok, :none, client_id}
else
{:error, error(@error_invalid_client, "client authentication required")}
end
end
end
defp fetch_body_credentials(_params, _policy) do
{:error, error(@error_invalid_client, "client authentication required")}
end
# RFC 7617 §2 / RFC 6749 §2.3.1: the userid and password are
# `application/x-www-form-urlencoded`-encoded, colon-separated, base64.
defp decode_basic_credentials(encoded) do
with {:ok, decoded} <- Base.decode64(encoded),
[client_id, secret] <- String.split(decoded, ":", parts: 2) do
{:ok, :client_secret_basic, URI.decode_www_form(client_id), URI.decode_www_form(secret)}
else
_ -> {:error, error(@error_invalid_client, "malformed Basic authorization header")}
end
end
defp fetch_private_key_jwt_credentials(assertion_type, assertion) do
if assertion_type == ClientAssertion.assertion_type() do
{:ok, :private_key_jwt, assertion}
else
{:error, error(@error_invalid_client, @client_auth_failed)}
end
end
defp require_client_auth_method(config, method) do
case Map.get(config, :token_endpoint_auth_methods_supported) do
methods when is_list(methods) and methods != [] ->
if method in methods,
do: :ok,
else: {:error, error(@error_invalid_client, @client_auth_failed)}
_ ->
:ok
end
end
defp has_body_secret?(%{"client_secret" => secret}) when is_binary(secret) and secret != "", do: true
defp has_body_secret?(_params), do: false
# The `:load_client` callback's contract (see `AttestoPhoenix.Config`)
# carries both existence and the revocation gate: `{:ok, client}`,
# `{:error, :not_found}`, or `{:error, :revoked}`. Revocation is therefore
# checked here without a separate predicate (RFC 7009 semantics for an
# already-revoked client).
defp verify_confidential_client(config, client_id, secret, method) do
verify_client_secret = Config.verify_client_secret_fun(config)
case invoke(Config.load_client_fun(config), [client_id]) do
{:ok, client} ->
if invoke(verify_client_secret, [client, secret]) == true do
result(config, client, client_id, method)
else
{:error, error(@error_invalid_client, @client_auth_failed)}
end
_other ->
# RFC 6749 §2.3 / OWASP: do not leak whether the client exists or is
# revoked. Run a dummy verification so the lookup-failure path matches
# the wrong-secret path in observable timing, and return one message.
# The same resolved callback is used for the real and dummy verify so
# the two paths stay timing-matched.
_ = invoke(verify_client_secret, [:unknown_client, secret])
{:error, error(@error_invalid_client, @client_auth_failed)}
end
end
defp verify_private_key_jwt_client(config, policy, assertion) do
with {:ok, client_id} <- ClientAssertion.peek_client_id(assertion),
{:ok, client} <- resolve_client(config, client_id),
{:ok, jwks} <- client_jwks(config, client),
{:ok, claims} <-
ClientAssertion.verify(
assertion,
client_id,
policy.assertion_audiences,
jwks,
assertion_verify_opts(policy)
),
{:ok, result} <- result(config, client, client_id, :private_key_jwt),
:ok <- consume_client_assertion_jti(config, policy, client_id, claims) do
{:ok, result}
else
_other -> {:error, error(@error_invalid_client, @client_auth_failed)}
end
end
defp assertion_verify_opts(%Policy{} = policy) do
opts = [
max_lifetime: policy.assertion_max_lifetime,
accepted_algs: policy.assertion_signing_algs
]
case policy.assertion_enforce_fapi_alg_policy do
value when is_boolean(value) -> Keyword.put(opts, :enforce_fapi_alg_policy, value)
nil -> opts
end
end
# A CIMD client's `private_key_jwt` verification keys are the document's
# `jwks` / `jwks_uri` (RFC 7523 / OIDC Core §9), not the host's `:client_jwks`
# callback. A document that carried neither has no keys, so `private_key_jwt`
# is impossible for it and authentication fails closed.
defp client_jwks(_config, {:cimd, metadata}) do
case ClientIdMetadata.jwks(metadata) do
nil -> {:error, :missing_client_jwks}
jwks -> {:ok, jwks}
end
end
defp client_jwks(config, client) do
case Config.client_jwks_fun(config) do
nil ->
{:error, :missing_client_jwks}
callback ->
case invoke(callback, [client]) do
{:ok, jwks} -> {:ok, jwks}
jwks when is_map(jwks) or is_list(jwks) -> {:ok, jwks}
_other -> {:error, :missing_client_jwks}
end
end
end
defp consume_client_assertion_jti(config, policy, client_id, %{"jti" => jti}) when is_binary(jti) and jti != "" do
key = client_assertion_replay_key(client_id, jti)
case invoke(replay_check(config), [key, policy.assertion_max_lifetime]) do
:ok -> :ok
_other -> {:error, :assertion_replay}
end
end
defp consume_client_assertion_jti(_config, _policy, _client_id, _claims), do: {:error, :missing_jti}
defp client_assertion_replay_key(client_id, jti) do
digest = :crypto.hash(:sha256, "#{client_id}\0#{jti}")
"client_assertion:" <> Base.url_encode64(digest, padding: false)
end
defp replay_check(%Config{replay_check: nil}), do: &ReplayCache.check_and_record/2
defp replay_check(%Config{replay_check: callback}), do: callback
# RFC 6749 §2.1: a client identified without a secret may proceed only if
# it is a public client. A successful `:load_client` is sufficient
# identification, but a confidential client MUST authenticate with a
# secret (RFC 6749 §2.3.1): accepting it secretless would let anyone who
# knows its `client_id` impersonate it, with no PKCE backstop on
# client_credentials. The host's `:client_public?` callback is the
# public/confidential discriminator; it MUST return `true` for the
# secretless path to be allowed. A public client's security then rests on
# PKCE (RFC 7636), enforced by `Attesto.AuthorizationCode` when the code
# is redeemed. A revoked or unknown client - and a confidential client
# presenting no secret - fails closed with the single generic message.
defp load_public_client(config, client_id) do
with {:ok, client} <- resolve_client(config, client_id),
true <- client_public?(config, client),
{:ok, result} <- result(config, client, client_id, :none) do
{:ok, result}
else
_other -> {:error, error(@error_invalid_client, @client_auth_failed)}
end
end
# Resolve a client through the same registry/CIMD path used by authentication.
# Besides the secretless (`none`) and `private_key_jwt` paths, signed-token
# policy lookup reuses this function so a token's original `client_id` has
# identical resolution semantics. A CIMD `client_id` (an HTTPS URL, with the
# feature enabled) is dereferenced and wrapped as `{:cimd, metadata}`; any
# opaque identifier goes to the host's `:load_client` registry. Every failure
# becomes `:not_found`, revealing neither which path ran nor whether a client
# was revoked.
@doc false
@spec resolve_client(Config.t(), String.t()) :: {:ok, term()} | {:error, :not_found}
def resolve_client(%Config{} = config, client_id) when is_binary(client_id) and client_id != "" do
if ClientIdMetadata.cimd_client_id?(client_id, config) do
case ClientIdMetadata.resolve(client_id, config) do
{:ok, metadata} -> {:ok, {:cimd, metadata}}
{:error, _reason} -> {:error, :not_found}
end
else
case invoke(Config.load_client_fun(config), [client_id]) do
{:ok, client} -> {:ok, client}
_other -> {:error, :not_found}
end
end
end
def resolve_client(%Config{}, _client_id), do: {:error, :not_found}
# The public/confidential discriminator (RFC 6749 §2.1). Read defensively
# from the configuration; fail closed (treat as confidential, i.e. not
# public) when the host has not supplied the callback, so a deployment
# that forgets it cannot accidentally let confidential clients
# authenticate without a secret.
# A CIMD client holds no shared symmetric secret (the document validation
# strips `client_secret_*` and the symmetric auth methods), so it is a public
# client by construction - it relies on PKCE downstream. A registered client
# defers to the host's `:client_public?` discriminator.
defp client_public?(_config, {:cimd, _metadata}), do: true
# Absent the host's `:client_public?` callback the answer normally fails
# closed to `false`: an unclassified client must not be admitted on the
# secretless path, because doing so would let anyone who knows a confidential
# client's `client_id` authenticate as it.
#
# For a client the host marked NATIVE that default flips to `true`, because
# for a native app the fail-closed direction is the other way. RFC 8252 §8.4
# says an installed app's statically included secret MUST NOT be accepted as
# proof of identity, so treating an unclassified native client as
# confidential would accept exactly the credential §8.4 forbids. Marking a
# client native is itself the deliberate classification that makes it public;
# `none` + PKCE is the posture §8.1 prescribes for it.
#
# One rule, used by BOTH the secretless gate and the §8.4 secret refusal, so
# the two cannot disagree. An earlier split - fail-closed-to-false here,
# fail-closed-to-true for §8.4 - left a native client with no
# `:client_public?` callback refused on both paths and therefore unable to
# authenticate at all.
defp client_public?(config, client) do
case Config.client_public_fun(config) do
nil -> client_native?(config, client)
callback -> Callback.invoke(callback, [client]) == true
end
end
# The authenticated OAuth `client_id` (RFC 6749 §2.2) carried by the
# credentials is authoritative. A host callback may independently map its
# opaque client value to an identifier, but accepting a different identifier
# would authenticate one client while attributing the result to another. An
# absent mapping leaves the credential-carried identifier in place; a present
# mapping must agree exactly. CIMD resolution follows the same agreement rule,
# but obtains the identifier from the validated document and never consults
# the host callback.
defp result(config, client, presented_client_id, method)
when is_binary(presented_client_id) and presented_client_id != "" do
if native_client_auth_permitted?(config, client, method) do
case resolved_client_id(config, client) do
nil -> {:ok, authenticated_result(client, presented_client_id, method)}
^presented_client_id -> {:ok, authenticated_result(client, presented_client_id, method)}
_other -> {:error, error(@error_invalid_client, @client_auth_failed)}
end
else
{:error, error(@error_invalid_client, @client_auth_failed)}
end
end
defp result(_config, _client, _presented_client_id, _method) do
{:error, error(@error_invalid_client, @client_auth_failed)}
end
defp authenticated_result(client, presented_client_id, method) do
%Result{client: client, client_id: presented_client_id, method: method}
end
# RFC 8252 §8.4: an installed native app cannot keep a credential
# confidential. Its binary is distributed to every user's device, so anything
# shipped inside it - a `client_secret`, an assertion signing key - is
# readable by anyone who has the app, and an authorization server that accepts
# it is authenticating "possession of the app", not "possession of a secret".
# A client the host marks native (RFC 8252 / `:client_native?`) and public
# (RFC 6749 §2.1 / `:client_public?`) therefore authenticates with `none`; its
# security rests on PKCE instead (§8.1, enforced by
# `AttestoPhoenix.AuthorizationServer.RequestPolicy.require_pkce?/2`).
#
# Presenting `client_secret_basic`, `client_secret_post`, or `private_key_jwt`
# is refused even when the host registry happens to hold a matching credential
# - silently accepting it would let a secret extracted from one installed copy
# authenticate as the client forever. The refusal is the single generic
# `invalid_client` message every other authentication failure returns, so it
# reveals nothing about the client's registration.
#
# RFC 8252 §8.4 carves out exactly one case where a native client may still
# authenticate: per-instance credentials provisioned by dynamic registration,
# which are genuinely confidential because no two installs share them. That is
# the only reason this is scoped to native AND public rather than to native
# alone, and it is why the host must say so explicitly - see
# `native_client_public?/2`.
defp native_client_auth_permitted?(_config, _client, :none), do: true
defp native_client_auth_permitted?(config, client, _method) do
not native_secret_refused?(config, client)
end
# Whether RFC 8252 §8.4 refuses a credential from `client`: true when the host
# marks it native and it resolves as public.
#
# Exposed because the revocation endpoint parses client credentials itself
# rather than going through `authenticate/4`, and a hand-copied second
# implementation of this predicate would be free to drift from the one the
# token, PAR, introspection, device, and CIBA endpoints enforce. There is one
# rule and one place it lives.
@doc false
@spec native_secret_refused?(Config.t(), term()) :: boolean()
def native_secret_refused?(%Config{} = config, client) do
client_native?(config, client) and client_public?(config, client)
end
# The native-app discriminator (RFC 8252 / BCP 212). A CIMD client is
# identified by an `https` URL resolving to a document served over the
# network, not an installed app. A registered client defers to the host's
# `:client_native?` callback, which defaults to `false`: an unclassified
# client is not native, so this check cannot refuse an authentication the
# host never opted into.
defp client_native?(_config, {:cimd, _metadata}), do: false
defp client_native?(config, client) do
Callback.invoke(Config.client_native_fun(config), [client], false) == true
end
# A CIMD client's identifier is the URL its document is bound to; a registered
# client's is the host's `:client_id` callback (falling back to the presented
# identifier in `result/4` when absent).
defp resolved_client_id(_config, {:cimd, metadata}), do: ClientIdMetadata.client_id(metadata)
defp resolved_client_id(config, client) do
Callback.invoke(Config.client_id_fun(config), [client], nil)
end
# Callback invocation delegates to `AttestoPhoenix.Callback`, except that an
# absent (`nil`) callback is the `:no_callback` sentinel its callers branch
# on (rather than raising a FunctionClauseError).
defp invoke(nil, _args), do: :no_callback
defp invoke(callback, args), do: Callback.invoke(callback, args)
defp error(code, description) do
OAuthError.new(code_atom(code), description, status: 400)
end
defp code_atom(@error_invalid_request), do: :invalid_request
defp code_atom(@error_invalid_client), do: :invalid_client
end