Current section

Files

Jump to
attesto lib attesto step_up.ex
Raw

lib/attesto/step_up.ex

defmodule Attesto.StepUp.Requirement do
@moduledoc """
A normalized RFC 9470 step-up authentication requirement for a protected route.
Two independent dimensions, both optional but at least one required:
* `:acr_values` - the set of Authentication Context Class References the
presented token's `acr` claim must be one of (RFC 9470 §3 / OIDC Core
§2). Empty means "no `acr` constraint".
* `:max_age` - the maximum age, in seconds, of the end-user authentication
event: the token's `auth_time` must be no older than `now - max_age`.
`nil` means "no freshness constraint".
"""
defstruct acr_values: [], max_age: nil
@type t :: %__MODULE__{acr_values: [String.t()], max_age: non_neg_integer() | nil}
@doc """
Build and validate a requirement from a `%Requirement{}` or a keyword list
(`acr_values:` and/or `max_age:`).
Fail-closed at configuration time: a malformed `:acr_values` / `:max_age`, or a
requirement that constrains neither dimension, raises `ArgumentError` so a
misconfigured route is caught at boot rather than silently never challenging.
"""
@spec parse(t() | keyword()) :: t()
def parse(%__MODULE__{} = req), do: validate!(req)
def parse(opts) when is_list(opts) do
validate!(%__MODULE__{
acr_values: opts |> Keyword.get(:acr_values, []) |> List.wrap(),
max_age: Keyword.get(opts, :max_age)
})
end
defp validate!(%__MODULE__{acr_values: acrs, max_age: max_age} = req) do
if !(is_list(acrs) and Enum.all?(acrs, &valid_acr?/1)) do
raise ArgumentError,
"step-up :acr_values must be a list of non-empty acr token strings (no whitespace, quotes, commas, or backslashes), got: #{inspect(acrs)}"
end
if !(is_nil(max_age) or (is_integer(max_age) and max_age >= 0)) do
raise ArgumentError, "step-up :max_age must be a non-negative integer or nil, got: #{inspect(max_age)}"
end
if acrs == [] and is_nil(max_age) do
raise ArgumentError, "step-up requirement must constrain :acr_values or :max_age (or both)"
end
req
end
# RFC 9470 §3: an `acr` value is echoed into a `WWW-Authenticate` quoted-string
# challenge. `acr` is host-configured (trusted), but constraining it to a safe
# token charset at config time forecloses any quote/param breakout regardless.
defp valid_acr?(value) when is_binary(value) and value != "", do: not Regex.match?(~r/[\s",\\]/, value)
defp valid_acr?(_value), do: false
end
defmodule Attesto.StepUp do
@moduledoc """
RFC 9470 Step-Up Authentication Challenge — the conn-free core primitive.
A resource server that needs a fresher or stronger end-user authentication for
a sensitive operation evaluates the presented (already verified) access token
against an `Attesto.StepUp.Requirement` and, when it is not satisfied, answers
RFC 9470 §3 `WWW-Authenticate: Bearer error="insufficient_user_authentication"`
with the `acr_values` / `max_age` the client must re-request at the
authorization endpoint.
This module is conn-free: it reads only the token's `acr` / `auth_time` claims
and a caller-supplied `now`, decides satisfaction (the framing layer renders
the challenge), and never touches a `Plug.Conn`, a store, or a wall clock.
## Semantics
* The two dimensions are a **conjunction** (RFC 9470 §4): the token must
satisfy the `acr` set membership AND the `auth_time` freshness bound.
* **Fail-closed**: a token whose `acr` is absent/non-string (when an
`acr_values` set is required), or whose `auth_time` is absent/non-integer
(when a `max_age` is required), does NOT satisfy the requirement. A token
from a non-OIDC grant (no `auth_time`) therefore always challenges a
freshness requirement — step-up routes are for end-user grants.
"""
alias Attesto.StepUp.Requirement
@error :insufficient_user_authentication
@typedoc "The RFC 9470 §3 challenge parameters naming what the client must re-request."
@type challenge :: %{optional(:acr_values) => String.t(), optional(:max_age) => non_neg_integer()}
@doc """
Evaluate verified token `claims` against `requirement` at `now` (unix seconds).
Returns `:ok`, or `{:error, :insufficient_user_authentication, challenge}` where
`challenge` carries the `acr_values` (space-delimited) / `max_age` the client
must re-request.
"""
@spec evaluate(Requirement.t(), map(), integer()) ::
:ok | {:error, :insufficient_user_authentication, challenge()}
def evaluate(%Requirement{} = requirement, claims, now) when is_map(claims) and is_integer(now) do
if satisfied?(requirement, claims, now),
do: :ok,
else: {:error, @error, challenge_params(requirement)}
end
@doc """
Whether the token's `acr` / `auth_time` claims satisfy `requirement` at `now`
(the conjunction of both dimensions; fail-closed on absent/malformed claims).
"""
@spec satisfied?(Requirement.t(), map(), integer()) :: boolean()
def satisfied?(%Requirement{acr_values: acr_values, max_age: max_age}, claims, now)
when is_map(claims) and is_integer(now) do
acr_satisfied?(acr_values, claims) and auth_time_satisfied?(max_age, claims, now)
end
@doc """
The RFC 9470 §3 challenge parameters for `requirement`: `:acr_values`
(space-delimited) and/or `:max_age`. Transport-free — the plug owns the
`WWW-Authenticate` scheme (Bearer vs DPoP) and quoting.
"""
@spec challenge_params(Requirement.t()) :: challenge()
def challenge_params(%Requirement{acr_values: acr_values, max_age: max_age}) do
%{}
|> maybe_put(:acr_values, if(acr_values != [], do: Enum.join(acr_values, " ")))
|> maybe_put(:max_age, max_age)
end
# An empty accepted set imposes no `acr` constraint; otherwise the token's
# `acr` MUST be a member. A missing/non-string `acr` fails closed.
defp acr_satisfied?([], _claims), do: true
defp acr_satisfied?(acr_values, claims) do
case Map.get(claims, "acr") do
acr when is_binary(acr) -> acr in acr_values
_ -> false
end
end
# `nil` max_age imposes no freshness constraint; otherwise `auth_time` MUST be
# no older than `now - max_age`. A missing/non-integer `auth_time` fails closed
# (RFC 9470 ties freshness to `auth_time`; there is no `iat` fallback).
defp auth_time_satisfied?(nil, _claims, _now), do: true
defp auth_time_satisfied?(max_age, claims, now) do
case Map.get(claims, "auth_time") do
auth_time when is_integer(auth_time) -> auth_time >= now - max_age
_ -> false
end
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end