Packages
attesto_phoenix
0.20.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/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.
"""
@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
@impl Plug
def init(opts) when is_list(opts), do: opts
@impl Plug
def call(conn, opts) do
config = resolve_config(opts)
claims_key = Keyword.get(opts, :claims_key, @claims_key)
case RequestContext.check_https(conn, config) do
:ok ->
conn =
conn
|> CoreAuthenticate.call(CoreAuthenticate.init(core_opts(config, claims_key, opts)))
if conn.halted do
emit_denied(config, conn, :invalid_token)
conn
else
reject_revoked_or_assign_principal(conn, config, claims_key, opts)
end
{:error, :insecure_transport} ->
emit_denied(config, conn, :insecure_transport)
OAuthError.unauthorized(
conn,
:bearer,
"invalid_token",
error_opts(config, description: "TLS required")
)
end
end
defp reject_revoked_or_assign_principal(conn, config, claims_key, opts) 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, []))
else
assign_principal(conn, config, claims_key, opts)
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) 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, []))
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) 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)
|> Keyword.merge(overrides)
end
defp configured_core_opts(config, claims_key) 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, config.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, extra) do
[
send_error: config.send_error,
www_authenticate: config.www_authenticate,
no_store: config.no_store,
resource_metadata: config.resource_metadata
]
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> 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