Packages
attesto_phoenix
0.13.2
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/discovery_controller.ex
defmodule AttestoPhoenix.Controller.DiscoveryController do
@moduledoc """
RFC 8414 - OAuth 2.0 Authorization Server Metadata endpoint.
Serves the discovery document at
`/.well-known/oauth-authorization-server` (RFC 8414 §3) so that clients
can discover the issuer, the endpoint URLs, and the capabilities the
authorization server advertises.
The document is assembled by `Attesto.Discovery.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), and the DPoP algorithms (`dpop_signing_alg_values_supported`, RFC
9449) - is derived by the core builder from the protocol configuration.
The capability members reflect exactly what the server supports:
`grant_types_supported` is read from `AttestoPhoenix.Config.grant_types_supported/1`
(every grant the token endpoint dispatches by default — `authorization_code`,
`refresh_token`, `client_credentials`, and OAuth token exchange — narrowed when
the host configures `:grant_types_supported`, and the token endpoint enforces
the same set); `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 PAR
endpoint is advertised separately as `pushed_authorization_request_endpoint`.
The host-specific members - the supported scopes (`scopes_supported`),
the authorization endpoint, 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. RFC 8414 §3.1 permits caching of the metadata
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 clients to endpoints that may not
exist.
"""
use Phoenix.Controller, formats: [:json]
import Plug.Conn, only: [put_resp_header: 3]
alias Attesto.AuthorizationRequest
alias Attesto.Discovery
alias Attesto.SigningAlg
alias AttestoPhoenix.AuthorizationServer.RequestObjectMetadata
alias AttestoPhoenix.Config
# The router pipeline installs the AttestoPhoenix.Config here. This is the
# same private key the token and revocation 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
# RFC 8414 §3: the metadata document is static for a given server
# configuration, so it may be cached by clients and intermediaries. One
# hour balances picking up configuration changes against request volume.
@cache_max_age_seconds 3600
# RFC 6749 §3.1.1 / §4.1: an authorization-code authorization server
# supports the "code" response type. Fixed by protocol, not configured.
@response_types_supported ["code"]
# RFC 8414 §2 / JARM §2.3 `response_modes_supported`: the response modes the
# authorization endpoint implements - the RFC 6749 default `query` and the
# JARM JWT modes (FAPI 2.0 Message Signing §5.4). Sourced from
# Attesto.AuthorizationRequest so the OAuth metadata matches the OpenID
# configuration and never drifts from what the request validator accepts.
@response_modes_supported AuthorizationRequest.supported_response_modes()
# 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 RFC 8414 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 clients.
"""
@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
|> Discovery.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
# Translate the configured host capabilities into the RFC 8414 §2 host
# members understood by Attesto.Discovery.metadata/2. The core builder
# drops nil-valued members, so the document advertises only what the
# server actually implements.
@spec discovery_opts(Config.t()) :: keyword()
defp discovery_opts(%Config{} = config) do
jar_alg_values = RequestObjectMetadata.signing_alg_values(config)
[
# RFC 8414 §2: the authorization endpoint is REQUIRED whenever any
# supported grant type uses it (authorization_code does). The core builder
# derives only issuer/token_endpoint, so it MUST be supplied here or the
# RFC 8414 document silently omits it (and an OAuth client - e.g. the
# ChatGPT MCP connector - that reads this document rather than OpenID
# Discovery cannot run the code flow). Derived from the same path
# resolution as token_endpoint so the two can never diverge.
authorization_endpoint: Config.authorize_endpoint_url(config),
response_types_supported: @response_types_supported,
response_modes_supported: @response_modes_supported,
grant_types_supported: Config.grant_types_supported(config),
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),
scopes_supported: presence(config.scopes_supported),
require_pushed_authorization_requests: require_pushed_authorization_requests(config),
pushed_authorization_request_endpoint: pushed_authorization_request_endpoint(config),
introspection_endpoint: Config.introspection_endpoint_url(config),
introspection_endpoint_auth_methods_supported: introspection_auth_methods(config),
registration_endpoint: registration_endpoint(config),
# RFC 9101 §10.5: the signed-request-object (JAR) metadata, derived from
# the same capability the OpenID Provider Metadata document uses, so a
# FAPI client reading RFC 8414 rather than OpenID Discovery sees identical
# JAR support. Both members are nil-dropped by the core builder when JAR
# is unsupported / not required.
request_object_signing_alg_values_supported: jar_alg_values,
require_signed_request_object: RequestObjectMetadata.require_signed(config),
# draft-ietf-oauth-client-id-metadata-document-01 §6: advertise that the
# server dereferences an HTTPS `client_id` URL to a client metadata
# document, but only when the feature is enabled. nil otherwise so the
# core builder drops the member entirely.
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(config)
|> put_introspection_signing_alg_values_supported(config)
|> put_authorization_response_iss_supported(config)
end
# RFC 9701 §10 `introspection_signing_alg_values_supported`: the algorithms the
# introspection endpoint signs JWT responses with - the server's own signing
# keys, the same set used for ID Tokens and JARM. Omitted when none.
defp put_introspection_signing_alg_values_supported(metadata, %Config{keystore: keystore}) do
case SigningAlg.keystore_algs(keystore) do
[] -> 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 - the server's own signing keys (the same set the OpenID configuration
# advertises as id_token_signing_alg_values_supported). Omitted when the
# keystore exposes none.
defp put_authorization_signing_alg_values_supported(metadata, %Config{keystore: keystore}) do
case SigningAlg.keystore_algs(keystore) do
[] -> 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
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 (RFC 8414 §2
# endpoint members are absolute URLs), so it reflects where the host mounted
# the endpoint rather than a hardcoded `/oauth/register`.
@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.
@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