defmodule AttestoPhoenix.Router do
@moduledoc """
Router macro that mounts the authorization-server endpoints.
`use AttestoPhoenix.Router` makes the `attesto_routes/1` macro available
inside a `Phoenix.Router`. Calling it inside (or alongside) a `scope`
declares the OAuth 2.0 / OpenID Connect server surface:
* `GET /.well-known/oauth-authorization-server` - authorization-server
metadata (RFC 8414 §3).
* `GET /.well-known/openid-configuration` - OpenID Provider configuration
(OpenID Connect Discovery 1.0 §4).
* `GET /.well-known/jwks.json` - the JSON Web Key Set of the verification
keys (RFC 7517 §5; the discovery document's `jwks_uri` per RFC 8414 §2).
* `GET /.well-known/oauth-protected-resource` - protected-resource metadata
(RFC 9728 §3), the discovery target of the §5.1 `WWW-Authenticate`
challenge the resource-server plugs emit.
* `GET /oauth/authorize` - the authorization endpoint (RFC 6749 §3.1;
OpenID Connect Core 1.0 §3.1.2).
* `POST /oauth/token` - the token endpoint (RFC 6749 §3.2).
* `POST /oauth/par` - pushed authorization requests (RFC 9126).
* `POST /oauth/revoke` - the token revocation endpoint (RFC 7009 §2).
* `POST /oauth/introspect` - the token introspection endpoint (RFC 7662 §2),
with the RFC 9701 signed-JWT response negotiated by the `Accept` header.
* `POST /oauth/register` - dynamic client registration (RFC 7591 §3.1),
mounted only when registration is enabled (see `:registration` below).
* `DELETE /oauth/register/:client_id` - dynamic client registration
management cleanup (RFC 7592 §2), mounted with registration.
* `GET` and `POST /oauth/userinfo` - the UserInfo endpoint (OpenID Connect
Core 1.0 §5.3); a bearer-authenticated protected resource (RFC 6750 §2.1).
The macro emits nothing but `Phoenix.Router` route entries pointing at this
library's controllers; it holds no policy of its own. Every behavioral
decision (which clients exist, which scopes are granted, whether DPoP / mTLS
binding is offered, whether registration is open) is owned by the host
through `AttestoPhoenix.Config`, which the controllers read at request time.
## Placement and pipelines
The discovery, OpenID configuration, and JWKS documents are unauthenticated
public metadata (RFC 8414 §5; OpenID Connect Discovery 1.0 §4; RFC 8615).
The authorization endpoint does not authenticate the client (RFC 6749 §3.1):
the resource owner authenticates through the host's login/consent callbacks,
so it carries no client-authentication pipeline. The token, revocation, and
registration endpoints authenticate the client from the request itself
(RFC 6749 §2.3, RFC 7009 §2, RFC 7591 §3), and the UserInfo endpoint is
bearer-authenticated from the `Authorization` header (RFC 6750 §2.1) by its
controller, rather than from a caller session, so they too take no
session-bearing pipeline. Supply a `:pipeline` only to attach
transport-level concerns the host wants in front of every endpoint (for
example a parser that accepts `application/x-www-form-urlencoded` at the
token endpoint per RFC 6749 §4.4.2, or an HTTPS-enforcing plug).
scope "/" do
attesto_routes()
end
# or with a host pipeline and a mount prefix:
scope "/" do
attesto_routes(pipeline: :oauth_server, prefix: "/auth")
end
## Options
* `:prefix` - path segment prepended to the `/oauth/*` endpoints (the
well-known documents always live at the host root per RFC 8615, so the
prefix does not apply to them). Defaults to `""`.
* `:pipeline` - a pipeline name (atom) or list of pipeline names to
`pipe_through` for the mounted routes. Defaults to `[]` (no extra
pipeline; the surrounding `scope`'s `pipe_through`, if any, still
applies).
* `:registration` - when `true`, mounts `POST /oauth/register`
(RFC 7591) and `DELETE /oauth/register/:client_id` (RFC 7592). Defaults
to `false`. The endpoints still fail closed at request time unless the
host has wired the registration callbacks in `AttestoPhoenix.Config`;
this option only controls whether the routes exist, so a deployment that
never offers registration presents no registration surface at all.
The library never inspects `:registration` to make a policy decision: it is
a route-existence toggle. Authorization-server metadata advertised at the
discovery endpoint is derived from `AttestoPhoenix.Config` by the discovery
controller, not from these macro options.
"""
# Well-known paths are fixed by their registries and are NOT subject to the
# host's `:prefix`. RFC 8414 §3 pins authorization-server metadata to the
# `/.well-known/oauth-authorization-server` URI, and RFC 8615 reserves the
# `/.well-known/` path segment at the host root. RFC 7517 §5 defines the JWK
# Set document the metadata's `jwks_uri` points at.
alias AttestoPhoenix.Controller.AuthorizeController
alias AttestoPhoenix.Controller.DeviceAuthorizationController
alias AttestoPhoenix.Controller.DeviceVerificationController
alias AttestoPhoenix.Controller.DiscoveryController
alias AttestoPhoenix.Controller.EndSessionController
alias AttestoPhoenix.Controller.IntrospectionController
alias AttestoPhoenix.Controller.JWKSController
alias AttestoPhoenix.Controller.OpenIDConfigurationController
alias AttestoPhoenix.Controller.PARController
alias AttestoPhoenix.Controller.ProtectedResourceController
alias AttestoPhoenix.Controller.RegistrationController
alias AttestoPhoenix.Controller.RevocationController
alias AttestoPhoenix.Controller.TokenController
alias AttestoPhoenix.Controller.UserinfoController
@discovery_path "/.well-known/oauth-authorization-server"
@jwks_path "/.well-known/jwks.json"
# OpenID Connect Discovery 1.0 §4 pins the OpenID Provider configuration
# document to the `/.well-known/openid-configuration` URI, also anchored at
# the host root under RFC 8615 and therefore NOT subject to the `:prefix`.
@openid_configuration_path "/.well-known/openid-configuration"
# RFC 9728 §3 pins the protected-resource metadata document to the
# `/.well-known/oauth-protected-resource` URI, anchored at the host root under
# RFC 8615 and therefore NOT subject to the `:prefix`. It is the discovery
# target of the RFC 9728 §5.1 `WWW-Authenticate: Bearer ..., resource_metadata`
# challenge the protected-resource plugs emit.
@protected_resource_path "/.well-known/oauth-protected-resource"
# The OAuth endpoints live under the host-chosen `:prefix`. These are the
# path tails appended to it. They derive from the SAME tail constants
# `AttestoPhoenix.Config` resolves its advertised endpoint URLs from, joined
# onto the default OAuth prefix (`"/oauth"`), so the routes this macro mounts
# and the routes the discovery documents advertise cannot drift: a host that
# mounts at `/oauth/*` (the default) and configures the matching default
# `:oauth_path_prefix` advertises exactly the paths mounted here.
@oauth_prefix "/oauth"
@authorize_path @oauth_prefix <> AttestoPhoenix.Config.authorize_tail()
@token_path @oauth_prefix <> AttestoPhoenix.Config.token_tail()
@par_path @oauth_prefix <> AttestoPhoenix.Config.par_tail()
@revoke_path @oauth_prefix <> AttestoPhoenix.Config.revocation_tail()
@introspect_path @oauth_prefix <> AttestoPhoenix.Config.introspection_tail()
@register_path @oauth_prefix <> AttestoPhoenix.Config.registration_tail()
@userinfo_path @oauth_prefix <> AttestoPhoenix.Config.userinfo_tail()
@device_authorization_path @oauth_prefix <> AttestoPhoenix.Config.device_authorization_tail()
@device_verification_path @oauth_prefix <> AttestoPhoenix.Config.device_verification_tail()
@end_session_path @oauth_prefix <> AttestoPhoenix.Config.end_session_tail()
# Controllers that back each endpoint. Named here once so the macro
# expansion does not scatter controller module references through the
# callers' router source.
@discovery_controller DiscoveryController
@protected_resource_controller ProtectedResourceController
@openid_configuration_controller OpenIDConfigurationController
@jwks_controller JWKSController
@authorize_controller AuthorizeController
@token_controller TokenController
@par_controller PARController
@revocation_controller RevocationController
@introspection_controller IntrospectionController
@registration_controller RegistrationController
@userinfo_controller UserinfoController
@device_authorization_controller DeviceAuthorizationController
@device_verification_controller DeviceVerificationController
@end_session_controller EndSessionController
@doc false
defmacro __using__(_opts) do
quote do
import AttestoPhoenix.Router, only: [attesto_routes: 0, attesto_routes: 1]
end
end
@doc """
Mounts the authorization-server endpoints. See the module documentation for
the route table and the accepted options.
"""
defmacro attesto_routes(opts \\ []) do
prefix = Keyword.get(opts, :prefix, "")
pipelines = opts |> Keyword.get(:pipeline, []) |> List.wrap()
registration? = Keyword.get(opts, :registration, false)
device? = Keyword.get(opts, :device, false)
logout? = Keyword.get(opts, :logout, false)
discovery_path = @discovery_path
protected_resource_path = @protected_resource_path
openid_configuration_path = @openid_configuration_path
jwks_path = @jwks_path
authorize_path = @authorize_path
token_path = @token_path
par_path = @par_path
revoke_path = @revoke_path
introspect_path = @introspect_path
register_path = @register_path
userinfo_path = @userinfo_path
discovery_controller = @discovery_controller
protected_resource_controller = @protected_resource_controller
openid_configuration_controller = @openid_configuration_controller
jwks_controller = @jwks_controller
authorize_controller = @authorize_controller
token_controller = @token_controller
par_controller = @par_controller
revocation_controller = @revocation_controller
introspection_controller = @introspection_controller
registration_controller = @registration_controller
userinfo_controller = @userinfo_controller
device_authorization_path = @device_authorization_path
device_verification_path = @device_verification_path
device_authorization_controller = @device_authorization_controller
device_verification_controller = @device_verification_controller
end_session_path = @end_session_path
end_session_controller = @end_session_controller
# `pipe_through/1` is a compile-time `Phoenix.Router` macro: it must be
# expanded once per pipeline as it is written into the scope, not iterated
# at runtime. Unroll the requested pipelines into individual quoted calls
# at macro-expansion time (an empty list yields no calls, piping through
# nothing extra) so a host that wires a parser / HTTPS pipeline attaches it
# to this server scope only, never leaking onto unrelated routes.
pipe_through_calls =
for attesto_pipeline <- pipelines do
quote do
pipe_through(unquote(attesto_pipeline))
end
end
# The registration routes are emitted only when the host opts in (RFC 7591
# §3.1 / RFC 7592 §2), decided here at expansion time so a deployment that
# never registers clients exposes no registration endpoint at all.
registration_route =
if registration? do
quote do
post(
unquote(prefix <> register_path),
unquote(registration_controller),
:create
)
delete(
unquote(prefix <> register_path <> "/:client_id"),
unquote(registration_controller),
:delete
)
end
end
# RFC 8628 §3.1: the device authorization endpoint is emitted only when the
# host opts in (`device: true`), so a deployment that does not offer the
# device grant exposes no device endpoint at all.
device_route =
if device? do
quote do
post(
unquote(prefix <> device_authorization_path),
unquote(device_authorization_controller),
:create
)
# RFC 8628 §3.3: the user-facing verification page. GET shows the
# confirm prompt (and pre-fills `?user_code=` from
# `verification_uri_complete`); POST carries the explicit approve/deny
# decision (no approval is ever derived from a GET / the URL alone).
get(
unquote(prefix <> device_verification_path),
unquote(device_verification_controller),
:verify
)
post(
unquote(prefix <> device_verification_path),
unquote(device_verification_controller),
:verify
)
end
end
# OpenID Connect RP-Initiated Logout 1.0 §2: the end-session endpoint is
# emitted only when the host opts in (`logout: true`). It accepts both GET
# (the RP-redirect navigation) and POST (form-submitted logout).
logout_route =
if logout? do
quote do
get(
unquote(prefix <> end_session_path),
unquote(end_session_controller),
:end_session
)
post(
unquote(prefix <> end_session_path),
unquote(end_session_controller),
:end_session
)
end
end
quote do
scope "/" do
unquote_splicing(pipe_through_calls)
# RFC 8615: the well-known documents are anchored at the host root and
# are not relocated by the host's `:prefix`. RFC 8414 §3 (OAuth
# authorization-server metadata) and OpenID Connect Discovery 1.0 §4
# (OpenID Provider configuration) are both unauthenticated public
# metadata served at their registered URIs.
get(unquote(discovery_path), unquote(discovery_controller), :show)
get(unquote(openid_configuration_path), unquote(openid_configuration_controller), :show)
get(unquote(jwks_path), unquote(jwks_controller), :show)
# RFC 9728 §3: the protected-resource metadata document is unauthenticated
# public metadata served at its registered well-known URI at the host
# root (RFC 8615), so a client following the RFC 9728 §5.1
# `WWW-Authenticate` challenge can discover the authorization server.
get(unquote(protected_resource_path), unquote(protected_resource_controller), :show)
# RFC 6749 §3.1 / OpenID Connect Core 1.0 §3.1.2: the authorization
# endpoint accepts both GET and POST under the host-chosen prefix. It
# carries no client-authentication pipeline (RFC 6749 §3.1: the client
# is not authenticated here; the resource owner authenticates through
# the host's login/consent callbacks).
get(unquote(prefix <> authorize_path), unquote(authorize_controller), :authorize)
post(unquote(prefix <> authorize_path), unquote(authorize_controller), :authorize)
# RFC 6749 §3.2 / RFC 7009 §2: token issuance and revocation are POST
# endpoints under the host-chosen prefix. They authenticate the client
# from the request itself (RFC 6749 §2.3, RFC 7009 §2).
post(unquote(prefix <> token_path), unquote(token_controller), :create)
post(unquote(prefix <> par_path), unquote(par_controller), :create)
post(unquote(prefix <> revoke_path), unquote(revocation_controller), :create)
# RFC 7662 §2: token introspection is a POST endpoint that authenticates
# the client from the request (RFC 7662 §2.1); RFC 9701 adds the signed
# JWT response negotiated by the Accept header.
post(unquote(prefix <> introspect_path), unquote(introspection_controller), :create)
unquote(registration_route)
unquote(device_route)
unquote(logout_route)
# OpenID Connect Core 1.0 §5.3.1: the UserInfo endpoint accepts both
# GET and POST, and is a bearer-authenticated protected resource
# (RFC 6750 §2.1). The controller verifies the presented access token
# from the `Authorization` header before returning any claim, so the
# endpoint authenticates from the request itself rather than from a
# caller session.
get(unquote(prefix <> userinfo_path), unquote(userinfo_controller), :userinfo)
post(unquote(prefix <> userinfo_path), unquote(userinfo_controller), :userinfo)
end
end
end
end