Current section

Files

Jump to
attesto_phoenix lib attesto_phoenix controller openid_configuration_controller.ex
Raw

lib/attesto_phoenix/controller/openid_configuration_controller.ex

defmodule AttestoPhoenix.Controller.OpenIDConfigurationController do
@moduledoc """
OpenID Connect Discovery 1.0 - OpenID Provider Metadata endpoint.
Serves the OpenID Provider configuration document at
`/.well-known/openid-configuration` (OpenID Connect Discovery §4) so that
Relying Parties can discover the OpenID Provider: the issuer, the endpoint
URLs, the response/grant types it supports, the signing algorithms it uses
for ID Tokens, and the scopes and claims it can return.
The document is assembled by `Attesto.OpenIDDiscovery.metadata/2`; this
controller contributes transport concerns only and adds no policy of its
own. Every protocol member - the issuer, the token endpoint
(`token_endpoint`), the JWKS location (`jwks_uri`), the PKCE challenge
methods (`code_challenge_methods_supported`, fixed to `S256` per RFC 7636
§4.2), the DPoP algorithms (`dpop_signing_alg_values_supported`, RFC 9449),
and the OIDC-fixed members (`subject_types_supported`,
`id_token_signing_alg_values_supported`, `claim_types_supported`) - is
derived by the core builder from the protocol configuration.
The capability members reflect exactly what the controllers wire, never an
aspirational superset: `grant_types_supported` lists the grants the token
endpoint dispatches (`authorization_code`, `refresh_token`,
`client_credentials`, and OAuth token exchange); `token_endpoint_auth_methods_supported`
lists the client-authentication methods it accepts (`client_secret_basic`,
`client_secret_post`, `private_key_jwt`, and `none` for PKCE-using public
clients). The OpenID Connect request-parameter flags
(`request_parameter_supported`, `request_uri_parameter_supported`, both
OpenID Connect Discovery §3) reflect the authorization endpoint precisely:
signed request objects (`request`, JAR/RFC 9101) are consumed when the host
supplies `:client_jwks`; arbitrary OIDC `request_uri` references are not
advertised even though PAR request URNs are resolved through `/oauth/par`. The
`claims_parameter_supported` flag (OpenID Connect Discovery §3 / OpenID
Connect Core §5.5) is host-configurable and defaults to `false`, since the
authorization endpoint does not consume the `claims` parameter unless the
host wires it.
The host-specific members - the `authorization_endpoint` (RFC 6749 §3.1)
and `userinfo_endpoint` (OpenID Connect Core §5.3), both host-owned and
hence not mounted by `AttestoPhoenix.Router`; the supported scopes
(`scopes_supported`, to which the core builder adds the reserved `openid`
scope per OpenID Connect Core §3.1.2.1); the supported claims
(`claims_supported`); the supported ACR values (`acr_values_supported`,
OpenID Connect Discovery §3) and UI locales (`ui_locales_supported`,
OpenID Connect Discovery §3), each advertised only when the host configures
a non-empty list; the `claims_parameter_supported` flag; and the dynamic
registration endpoint (`registration_endpoint`, RFC 7591, advertised only
when registration is enabled) - are read from `AttestoPhoenix.Config` and
passed through, never hardcoded here.
The response carries no secrets and is identical for every caller, so it is
served unauthenticated. OpenID Connect Discovery §4 permits caching of the
configuration response, so a public, cacheable `Cache-Control` header is
set.
## Wiring
The router pipeline must place the `AttestoPhoenix.Config` under
`conn.private[:attesto_phoenix_config]` (the same key the other endpoints
read) and the derived `Attesto.Config` under
`conn.private[:attesto_protocol_config]`. Both are required; a missing value
raises rather than serving a partial document, because a partial discovery
document would misdirect Relying Parties to endpoints that may not exist.
"""
use Phoenix.Controller, formats: [:json]
import Plug.Conn, only: [put_resp_header: 3]
alias Attesto.AuthorizationRequest
alias Attesto.OpenIDDiscovery
alias AttestoPhoenix.AuthorizationServer.RequestObjectMetadata
alias AttestoPhoenix.Config
# The router pipeline installs the AttestoPhoenix.Config here. This is the
# same private key the token and discovery endpoints read.
@config_key :attesto_phoenix_config
# The router pipeline installs the derived Attesto.Config (the protocol
# configuration the core metadata builder reads) here.
@protocol_config_key :attesto_protocol_config
# OpenID Connect Discovery §4: the configuration document is static for a
# given provider configuration, so it may be cached by Relying Parties and
# intermediaries. One hour balances picking up configuration changes against
# request volume, matching the RFC 8414 discovery endpoint.
@cache_max_age_seconds 3600
# RFC 6749 §3.1.1 / §4.1: an authorization-code provider supports the "code"
# response type. Fixed by protocol, not configured. OpenID Connect Discovery
# requires response_types_supported; the core builder defaults to this when
# the host does not override it.
@response_types_supported ["code"]
# OpenID Connect Core §3.1.2.5 / RFC 8414 §2 / JARM §2.3 `response_modes_
# supported`: the response modes the authorization endpoint implements - the
# RFC 6749 default `query` and the JWT Secured Authorization Response Mode
# variants (FAPI 2.0 Message Signing §5.4). Sourced from
# Attesto.AuthorizationRequest so the advertisement never drifts from what the
# request validator accepts and the controller emits.
@response_modes_supported AuthorizationRequest.supported_response_modes()
# RFC 8414 §2 `grant_types_supported`: the grant types the token endpoint
# (`AttestoPhoenix.Controller.TokenController`) actually dispatches -
# `authorization_code` (RFC 6749 §4.1), `refresh_token` (RFC 6749 §6), and
# `client_credentials` (RFC 6749 §4.4). Any other `grant_type` is rejected
# with `unsupported_grant_type`, so only these three are advertised. Fixed by
# what the controller wires, not configured.
@grant_types_supported [
"authorization_code",
"refresh_token",
"client_credentials",
"urn:ietf:params:oauth:grant-type:token-exchange"
]
# RFC 8414 §2 `token_endpoint_auth_methods_supported`: the client
# authentication methods the token endpoint actually accepts. The controller
# reads a confidential client's secret from an HTTP Basic header
# (`client_secret_basic`, RFC 6749 §2.3.1 / RFC 7617), from the request body
# (`client_secret_post`, RFC 6749 §2.3.1), or from a signed client assertion
# (`private_key_jwt`, RFC 7523 / OIDC Core §9). It also admits a public
# client that presents only a `client_id` and relies on PKCE (`none`,
# RFC 6749 §2.1 / RFC 7636). Fixed by what the controller wires.
@token_endpoint_auth_methods_supported [
"client_secret_basic",
"client_secret_post",
"private_key_jwt",
"none"
]
@doc """
Render the OpenID Provider Metadata document as JSON.
Fails closed with `RuntimeError` when either required configuration value is
absent from `conn.private`, since serving a document that omits required
members would misdirect Relying Parties.
"""
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, _params) do
config = fetch_config!(conn)
protocol_config = fetch_protocol_config!(conn)
metadata =
protocol_config
|> OpenIDDiscovery.metadata(discovery_opts(config))
|> put_fapi_metadata(config)
conn
|> put_cache_control()
|> json(metadata)
end
# Fail closed: a missing config is a wiring error, not a runtime condition to
# paper over. Raising surfaces the misconfiguration instead of emitting a
# document that omits required members.
@spec fetch_config!(Plug.Conn.t()) :: Config.t()
defp fetch_config!(conn) do
case conn.private do
%{@config_key => %Config{} = config} ->
config
_ ->
raise "#{inspect(__MODULE__)}: no %AttestoPhoenix.Config{} found in " <>
"conn.private[#{inspect(@config_key)}]; wire the host pipeline that assigns it"
end
end
@spec fetch_protocol_config!(Plug.Conn.t()) :: Attesto.Config.t()
defp fetch_protocol_config!(conn) do
case conn.private do
%{@protocol_config_key => %Attesto.Config{} = config} ->
config
_ ->
raise "#{inspect(__MODULE__)}: no %Attesto.Config{} found in " <>
"conn.private[#{inspect(@protocol_config_key)}]; wire the host pipeline that assigns it"
end
end
# OpenID Connect Discovery §3 `request_uri_parameter_supported`: this server
# resolves PAR `request_uri` URNs it issued, but does not advertise arbitrary
# OIDC `request_uri` fetching. `request_parameter_supported` is derived per
# install from request-object capability (see request_objects_supported?/1).
@request_uri_parameter_supported false
# Translate the configured host capabilities into the OpenID Connect
# Discovery §3 host members understood by Attesto.OpenIDDiscovery.metadata/2.
# The core builder drops nil-valued members, so optional members advertise
# only what the provider actually implements. `scopes_supported` is always
# passed (never collapsed to nil): an OpenID Provider MUST support the
# reserved `openid` scope (OpenID Connect Core §3.1.2.1), so the core builder
# adds it to the host's catalog, yielding `["openid"]` even when the host
# configures no other scopes.
@spec discovery_opts(Config.t()) :: keyword()
defp discovery_opts(%Config{} = config) do
jar_alg_values = RequestObjectMetadata.signing_alg_values(config)
[
response_types_supported: @response_types_supported,
response_modes_supported: @response_modes_supported,
grant_types_supported: @grant_types_supported,
token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported(config),
token_endpoint_auth_signing_alg_values_supported: config.client_auth_signing_algs,
authorization_response_iss_parameter_supported: authorization_response_iss_parameter_supported(config),
authorization_endpoint: config.authorization_endpoint,
userinfo_endpoint: config.userinfo_endpoint,
revocation_endpoint: revocation_endpoint(config),
introspection_endpoint: Config.introspection_endpoint_url(config),
introspection_endpoint_auth_methods_supported: introspection_auth_methods(config),
require_pushed_authorization_requests: require_pushed_authorization_requests(config),
pushed_authorization_request_endpoint: pushed_authorization_request_endpoint(config),
scopes_supported: config.scopes_supported,
claims_supported: presence(config.claims_supported),
registration_endpoint: registration_endpoint(config),
# OpenID Connect Discovery §3 capability flags reflecting what is wired.
# `request_parameter_supported` tracks actual capability: the authorization
# endpoint can verify a signed request object only when the host can
# resolve a client's trusted JWKS, so an install without that capability
# advertises `false` rather than a JAR support it cannot honour.
request_parameter_supported: RequestObjectMetadata.supported?(config),
request_uri_parameter_supported: @request_uri_parameter_supported,
claims_parameter_supported: config.claims_parameter_supported,
# RFC 9101 §10.5 / FAPI 2.0 Message Signing §5.3.1: the request-object
# signing algorithms accepted, and (only when the policy mandates it) that
# signed request objects are required. The algorithm list is advertised
# only when request objects are actually supported, so discovery never
# drifts from enforcement.
request_object_signing_alg_values_supported: jar_alg_values,
require_signed_request_object: RequestObjectMetadata.require_signed(config),
# Host catalogs: advertised only when the host configures a non-empty list
# (the core builder drops the nil the helper returns for `[]`).
acr_values_supported: presence(config.acr_values_supported),
ui_locales_supported: presence(config.ui_locales_supported),
# draft-ietf-oauth-client-id-metadata-document-01 §6: advertise CIMD
# support only when the feature is enabled; nil otherwise so the shared
# Attesto.Discovery builder drops the member.
client_id_metadata_document_supported: client_id_metadata_document_supported(config)
]
end
defp client_id_metadata_document_supported(%Config{} = config) do
if Config.client_id_metadata_enabled?(config), do: true
end
defp token_endpoint_auth_methods_supported(%Config{token_endpoint_auth_methods_supported: methods})
when is_list(methods) and methods != [], do: methods
defp token_endpoint_auth_methods_supported(%Config{}), do: @token_endpoint_auth_methods_supported
# The introspection endpoint authenticates the caller and rejects the public
# ("none") path (RFC 7662 §2.1), so it advertises the confidential subset of
# the configured client-authentication methods.
defp introspection_auth_methods(config) do
Enum.reject(token_endpoint_auth_methods_supported(config), &(&1 == "none"))
end
defp put_fapi_metadata(metadata, %Config{} = config) do
metadata
|> Map.put(
"token_endpoint_auth_signing_alg_values_supported",
config.client_auth_signing_algs
)
|> put_authorization_signing_alg_values_supported()
|> put_introspection_signing_alg_values_supported()
|> put_authorization_response_iss_supported(config)
end
# RFC 9701 §10 `introspection_signing_alg_values_supported`: the algorithms the
# introspection endpoint signs JWT responses with. Signed with the same key as
# ID Tokens and JARM, so the advertised set is exactly the already-derived
# id_token_signing_alg_values_supported.
defp put_introspection_signing_alg_values_supported(metadata) do
case Map.get(metadata, "id_token_signing_alg_values_supported") do
nil -> metadata
algs -> Map.put(metadata, "introspection_signing_alg_values_supported", algs)
end
end
# JARM §3 / FAPI 2.0 Message Signing §5.4 `authorization_signing_alg_values_
# supported`: the algorithms the authorization endpoint signs JARM responses
# with. JARM responses are signed with the same keystore key as ID Tokens
# (Attesto.JARM mirrors Attesto.IDToken), so the advertised set is exactly the
# already-derived id_token_signing_alg_values_supported.
defp put_authorization_signing_alg_values_supported(metadata) do
case Map.get(metadata, "id_token_signing_alg_values_supported") do
nil -> metadata
algs -> Map.put(metadata, "authorization_signing_alg_values_supported", algs)
end
end
defp put_authorization_response_iss_supported(metadata, %Config{authorization_response_iss: true}) do
Map.put(metadata, "authorization_response_iss_parameter_supported", true)
end
defp put_authorization_response_iss_supported(metadata, %Config{}), do: metadata
defp require_pushed_authorization_requests(%Config{require_pushed_authorization_requests: true}), do: true
defp require_pushed_authorization_requests(%Config{}), do: nil
defp authorization_response_iss_parameter_supported(%Config{authorization_response_iss: true}), do: true
defp authorization_response_iss_parameter_supported(%Config{}), do: nil
# RFC 7009 §2 / RFC 8414 §2 `revocation_endpoint`: the revocation endpoint
# (`AttestoPhoenix.Controller.RevocationController`) is always mounted by the
# router macro, so it is always advertised. The URL is resolved from the
# host's configured revocation path (the endpoint members are absolute URLs),
# so it reflects where the host mounted the endpoint.
@spec revocation_endpoint(Config.t()) :: String.t()
defp revocation_endpoint(%Config{} = config), do: Config.revocation_endpoint_url(config)
defp pushed_authorization_request_endpoint(%Config{} = config), do: Config.par_endpoint_url(config)
# RFC 7591 §3: advertise the dynamic client registration endpoint only when
# registration is enabled; otherwise omit the member entirely. The URL is
# resolved from the host's configured registration path (the endpoint members
# are absolute URLs), so it reflects where the host mounted the endpoint.
@spec registration_endpoint(Config.t()) :: String.t() | nil
defp registration_endpoint(%Config{registration_enabled: true} = config), do: Config.registration_endpoint_url(config)
defp registration_endpoint(%Config{registration_enabled: false}), do: nil
# An empty list means "not advertised": collapse it to nil so the core
# builder omits the member instead of publishing an empty array. Used for the
# optional `claims_supported` catalog, not for `scopes_supported` (which is
# always advertised; see discovery_opts/1).
@spec presence([term()]) :: [term()] | nil
defp presence([]), do: nil
defp presence(list) when is_list(list), do: list
@spec put_cache_control(Plug.Conn.t()) :: Plug.Conn.t()
defp put_cache_control(conn) do
put_resp_header(
conn,
"cache-control",
"public, max-age=#{@cache_max_age_seconds}"
)
end
end