Packages
attesto_phoenix
2.1.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/backchannel_authentication_controller.ex
defmodule AttestoPhoenix.Controller.BackchannelAuthenticationController do
@moduledoc """
OpenID Connect CIBA backchannel authentication endpoint (CIBA Core 1.0 §7.1).
Handles `POST /oauth/bc-authorize`. This module owns the HTTP framing only: it
resolves the host `%AttestoPhoenix.Config{}`, applies no-store cache headers,
authenticates the client, lifts the request into a plain
`AttestoPhoenix.AuthorizationServer.BackchannelAuthentication.Request`, calls
the conn-free core, and renders the CIBA §7.3 acknowledgement JSON (or a
§13 / RFC 6749 §5.2 error). Every grant/binding decision lives in the core.
Unlike the device-authorization endpoint, CIBA is **confidential-clients-only**
(FAPI-CIBA §5.2.2): the client authenticates with `private_key_jwt` or mTLS
(`allow_public: false`), so a public/`:none` client is rejected.
The endpoint is served only when the host enables the grant
(`ciba: [enabled: true]`) AND mounts it (`ciba: true` on `attesto_routes/1`);
otherwise it responds `invalid_request` (the route should not be mounted at
all when disabled).
"""
use Phoenix.Controller, formats: [:json]
import Plug.Conn
alias AttestoPhoenix.AuthorizationServer.BackchannelAuthentication
alias AttestoPhoenix.AuthorizationServer.BackchannelAuthentication.Request
alias AttestoPhoenix.ClientAuthentication
alias AttestoPhoenix.ClientAuthentication.Policy
alias AttestoPhoenix.{Config, OAuthError, RequestContext}
# 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
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do
config = resolve_config()
conn = put_no_store(conn)
with :ok <- require_enabled(config),
:ok <- check_https(conn, config),
:ok <- reject_query_credentials(conn),
{:ok, result} <- authenticate_client(config, conn, params),
{:ok, response} <- BackchannelAuthentication.request(config, build_request(config, conn, result, params)) do
json(conn, response)
else
{:error, %OAuthError{} = err} -> render_error(conn, err)
end
end
# RFC 6749 §2.3.1: credentials and request params belong in the form body.
# Query-string credentials leak through access logs, caches, and browser
# history, so reject them before client authentication (as the token/device
# endpoints do), since Phoenix merges query and body params for the action.
@query_credential_params ~w(client_id client_secret scope)
defp reject_query_credentials(conn) do
conn = fetch_query_params(conn)
case Enum.find(@query_credential_params, &Map.has_key?(conn.query_params, &1)) do
nil ->
:ok
key ->
{:error,
OAuthError.new(:invalid_request, "#{key} must be sent in the request body, not the query string", status: 400)}
end
end
defp require_enabled(config) do
if Config.ciba_enabled?(config),
do: :ok,
else: {:error, OAuthError.new(:invalid_request, "CIBA is not enabled", status: 400)}
end
defp check_https(conn, config) do
case RequestContext.check_https(conn, config) do
:ok -> :ok
{:error, :insecure_transport} -> {:error, OAuthError.new(:invalid_request, "TLS required", status: 400)}
end
end
# FAPI-CIBA §5.2.2: the backchannel authentication endpoint accepts confidential
# clients only (`private_key_jwt` / mTLS), so `allow_public: false`. The client
# assertion `aud` may be the issuer identifier, the token endpoint URL, or this
# backchannel endpoint's own URL (RFC 7523 §3: any value that identifies the
# AS — the conformance suite exercises all three), all derived from trusted
# `Config` (never the request `Host`).
defp authenticate_client(config, conn, params) do
policy = %Policy{
allow_public: false,
assertion_audiences: [
config.issuer,
Config.token_endpoint_url(config),
Config.backchannel_authentication_endpoint_url(config)
],
assertion_max_lifetime: @client_assertion_max_lifetime,
assertion_signing_algs: config.client_auth_signing_algs,
assertion_enforce_fapi_alg_policy: config.client_auth_enforce_fapi_alg_policy
}
case ClientAuthentication.authenticate_with_context(get_req_header(conn, "authorization"), params, config, policy) do
{:ok, %ClientAuthentication.Result{} = result} -> {:ok, result}
{:error, %OAuthError{} = err, _context} -> {:error, err}
end
end
defp build_request(config, conn, %ClientAuthentication.Result{} = result, params) do
%Request{
client: result.client,
client_auth_method: result.method,
request_client_id: result.client_id,
client_ip: RequestContext.client_ip(conn, config),
params: params
}
end
defp resolve_config do
otp_app = Application.get_env(:attesto_phoenix, :otp_app)
Config.from_otp_app(otp_app, Config)
end
defp render_error(conn, %OAuthError{} = err) do
conn
|> merge_resp_headers(err.headers)
|> put_status(err.status)
|> json(error_body(err))
end
defp error_body(%OAuthError{error: code, error_description: nil}), do: %{error: code}
defp error_body(%OAuthError{error: code, error_description: desc}), do: %{error: code, error_description: desc}
defp put_no_store(conn) do
conn
|> put_resp_header("cache-control", "no-store")
|> put_resp_header("pragma", "no-cache")
end
end