Packages
attesto_phoenix
2.0.1
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/plug/authenticate.ex
defmodule AttestoPhoenix.Plug.Authenticate do
@moduledoc """
Phoenix-friendly protected-resource authentication.
This plug is a thin integration layer over `Attesto.Plug.Authenticate`. The
core plug owns the protocol work: parsing Bearer/DPoP credentials, verifying
the JWT access token, enforcing DPoP and mTLS sender-constraint bindings, and
rendering RFC 6750 / RFC 9449 failures. This wrapper derives the core options
from `AttestoPhoenix.Config`, resolves the verified subject through the
host's `:load_principal` callback, and assigns neutral values for downstream
Phoenix code.
Defaults:
* `:claims_key` - `:attesto_claims`
* `:principal_key` - `:attesto_principal`
* `:context_key` - `:attesto_context`
The context assign is a map with `:subject`, `:client_id`, `:scope`, `:claims`,
`:cnf`, and `:principal`. It is deliberately protocol-shaped; application
policy such as accounts, roles, audit actors, and error envelopes belongs in
the host application.
RFC 9728 challenge discovery is selected through
`AttestoPhoenix.Config.resource_metadata_url/3`: an explicit per-plug
`:resource_metadata` value (including `nil`) takes precedence on every error
path and skips the resolver. A non-nil per-plug value is static Plug
configuration and is validated by `init/1`, which Phoenix runs at compile
time by default. Otherwise, the existing static
`:resource_metadata` URL remains the default, while an optional
`:resource_metadata_resolver` can choose a URL or omit it for each request.
Invalid runtime resolver results are omitted. A resolver exception is not
rescued and aborts the request, because resolvers are trusted host
configuration rather than untrusted request input.
"""
@behaviour Plug
import Plug.Conn
alias Attesto.DPoP.ReplayCache
alias Attesto.Plug.Authenticate, as: CoreAuthenticate
alias Attesto.Plug.OAuthError
alias AttestoPhoenix.{Callback, Config, Event, RequestContext}
alias AttestoPhoenix.Store.NonceStore
@claims_key :attesto_claims
@principal_key :attesto_principal
@context_key :attesto_context
@error_option_keys [:send_error, :www_authenticate, :no_store]
@impl Plug
def init(opts) when is_list(opts) do
case Keyword.fetch(opts, :resource_metadata) do
:error ->
opts
{:ok, nil} ->
opts
{:ok, candidate} ->
if Config.valid_resource_metadata_url?(candidate) do
opts
else
raise ArgumentError,
"AttestoPhoenix.Plug.Authenticate: :resource_metadata, when set, must be an " <>
"absolute https URL with a host and no fragment; got #{inspect(candidate)}."
end
end
end
@impl Plug
def call(conn, opts) do
config = resolve_config(opts)
claims_key = Keyword.get(opts, :claims_key, @claims_key)
resource_metadata = Config.resource_metadata_url(config, conn, opts)
case RequestContext.check_https(conn, config) do
:ok ->
conn =
conn
|> CoreAuthenticate.call(CoreAuthenticate.init(core_opts(config, claims_key, opts, resource_metadata)))
if conn.halted do
emit_denied(config, conn, :invalid_token)
conn
else
reject_revoked_or_assign_principal(conn, config, claims_key, opts, resource_metadata)
end
{:error, :insecure_transport} ->
emit_denied(config, conn, :insecure_transport)
OAuthError.unauthorized(
conn,
:bearer,
"invalid_token",
error_opts(config, opts, resource_metadata, description: "TLS required")
)
end
end
defp reject_revoked_or_assign_principal(conn, config, claims_key, opts, resource_metadata) do
claims = conn.assigns[claims_key]
if access_token_revoked?(config, claims) do
emit_denied(config, conn, :invalid_token)
OAuthError.unauthorized(
conn,
scheme_of(claims),
"invalid_token",
error_opts(config, opts, resource_metadata, [])
)
else
assign_principal(conn, config, claims_key, opts, resource_metadata)
end
end
defp access_token_revoked?(%Config{code_store: store}, %{"jti" => jti}) when is_atom(store) and is_binary(jti) do
function_exported?(store, :access_token_revoked?, 1) and store.access_token_revoked?(jti)
end
defp access_token_revoked?(_config, _claims), do: false
defp assign_principal(conn, config, claims_key, opts, resource_metadata) do
claims = conn.assigns[claims_key]
subject = claims["sub"]
case Callback.invoke(Config.load_principal_fun(config), [subject]) do
{:ok, principal} ->
principal_key = Keyword.get(opts, :principal_key, @principal_key)
context_key = Keyword.get(opts, :context_key, @context_key)
conn
|> assign(principal_key, principal)
|> assign(context_key, context(claims, principal))
|> tap(fn _conn -> emit_succeeded(config, claims) end)
{:error, _reason} ->
emit_denied(config, conn, :invalid_token)
OAuthError.unauthorized(
conn,
scheme_of(claims),
"invalid_token",
error_opts(config, opts, resource_metadata, [])
)
end
end
defp context(claims, principal) do
%{
subject: claims["sub"],
client_id: claims["client_id"],
scope: scope(claims),
claims: claims,
cnf: Map.get(claims, "cnf"),
principal: principal
}
end
defp scope(%{"scope" => scope}) when is_binary(scope), do: String.split(scope, ~r/\s+/, trim: true)
defp scope(_claims), do: []
defp scheme_of(%{"cnf" => %{"jkt" => jkt}}) when is_binary(jkt), do: :dpop
defp scheme_of(_claims), do: :bearer
defp core_opts(config, claims_key, opts, resource_metadata) do
overrides =
opts
|> Keyword.drop([:config, :otp_app, :claims_key, :principal_key, :context_key])
|> Keyword.put(:claims_key, claims_key)
config
|> configured_core_opts(claims_key, resource_metadata)
|> Keyword.merge(overrides)
# Normalize an explicit override (including nil/invalid) after the raw plug
# options are merged, so the delegated core path sees the same selected URL
# as the wrapper-owned TLS, revocation, and principal failures.
|> Keyword.put(:resource_metadata, resource_metadata)
end
defp configured_core_opts(config, claims_key, resource_metadata) do
[config: attesto_config(config), claims_key: claims_key]
|> Keyword.put(:bearer_methods, config.bearer_methods_supported)
|> put_optional(:send_error, config.send_error)
|> put_optional(:www_authenticate, config.www_authenticate)
|> put_optional(:no_store, config.no_store)
|> put_optional(:resource_metadata, resource_metadata)
|> put_optional(:replay_check, replay_check(config))
|> put_optional(:nonce_check, nonce_check(config))
|> put_optional(:nonce_issue, nonce_issue(config))
|> put_optional(:cert_der, cert_der(config))
|> Keyword.put(:htu, fn conn -> RequestContext.canonical_url(conn, config) end)
end
defp attesto_config(config) do
Config.to_attesto_config(config, principal_kinds_extra(config))
end
defp principal_kinds_extra(%Config{principal_kinds: kinds}) when is_list(kinds) and kinds != [] do
[principal_kinds: kinds]
end
defp principal_kinds_extra(%Config{principal_kinds: callback}) when not is_nil(callback) do
case Callback.invoke(callback, []) do
kinds when is_list(kinds) and kinds != [] -> [principal_kinds: kinds]
_ -> []
end
end
defp principal_kinds_extra(_config), do: []
defp replay_check(%Config{dpop_enabled: false}), do: nil
defp replay_check(%Config{replay_check: nil}), do: &ReplayCache.check_and_record/2
# A host configures `:replay_check` as a `{module, function}` MFA (config holds
# no literal fn), but `Attesto.DPoP.verify_proof/2` requires a bare 2-arity
# function. Adapt every callback form into a closure before handing it over.
defp replay_check(%Config{replay_check: callback}), do: Callback.to_fun2(callback)
defp nonce_check(%Config{dpop_nonce_required: true, nonce_store: store} = config)
when is_atom(store) and not is_nil(store) do
fn nonce ->
if NonceStore.valid?(config, store, nonce), do: :ok, else: {:error, :use_dpop_nonce}
end
end
defp nonce_check(_config), do: nil
# Thread the resolved config so a persistent store never has to re-resolve its
# repo from a guessed otp_app.
defp nonce_issue(%Config{dpop_nonce_required: true, nonce_store: store} = config)
when is_atom(store) and not is_nil(store) do
fn -> NonceStore.issue(config, store) end
end
defp nonce_issue(_config), do: nil
defp cert_der(%Config{mtls_enabled: true, cert_der: cert_der}) when not is_nil(cert_der),
do: normalize_callback(cert_der)
defp cert_der(_config), do: nil
defp emit_succeeded(config, claims) do
Event.emit(config, :auth_succeeded, %{
subject: claims["sub"],
client_id: claims["client_id"],
scope: claims["scope"]
})
end
defp emit_denied(config, conn, result) do
Event.emit(config, :auth_denied, %{
result: result,
metadata: request_metadata(conn, config)
})
end
defp request_metadata(conn, config) do
%{
method: conn.method,
path: conn.request_path,
client_ip: RequestContext.client_ip(conn, config)
}
end
defp resolve_config(opts) do
case Keyword.get(opts, :config) do
%Config{} = config ->
config
fun when is_function(fun, 0) ->
fun.()
nil ->
opts
|> Keyword.get(:otp_app, Application.get_env(:attesto_phoenix, :otp_app))
|> Config.from_otp_app(Config)
end
end
defp put_optional(opts, _key, nil), do: opts
defp put_optional(opts, key, value), do: Keyword.put(opts, key, value)
defp error_opts(config, plug_opts, resource_metadata, extra) do
[
send_error: config.send_error,
www_authenticate: config.www_authenticate,
no_store: config.no_store,
resource_metadata: resource_metadata
]
|> Keyword.merge(Keyword.take(plug_opts, @error_option_keys))
|> Keyword.merge(extra)
end
defp normalize_callback(callback) when is_function(callback), do: callback
defp normalize_callback({module, fun}) when is_atom(module) and is_atom(fun) do
fn conn -> apply(module, fun, [conn]) end
end
defp normalize_callback({module, fun, extra}) when is_atom(module) and is_atom(fun) do
fn conn -> apply(module, fun, [conn | extra]) end
end
end