Integration façade for Client ID Metadata Documents - CIMD
(draft-ietf-oauth-client-id-metadata-document-01, IETF OAuth WG).
CIMD lets a client identify itself with no prior registration by using an
HTTPS URL as its client_id; the authorization server dereferences that URL
to a JSON client metadata document and uses it as the client. The pure URL/
document validation lives in Attesto.ClientIdMetadata, the SSRF-guarded
fetch in AttestoPhoenix.ClientIdMetadata.Fetcher, the cache in
AttestoPhoenix.ClientIdMetadata.Cache, and the orchestration in
AttestoPhoenix.ClientIdMetadata.Resolver. This module is the thin seam the
HTTP endpoints (the authorization endpoint and the token / PAR client
authentication path) call to decide whether a presented client_id is a CIMD
URL and, when it is, to resolve it into a client.
When CIMD applies
A presented client_id is resolved through CIMD only when both hold
(cimd_client_id?/2):
- the feature is enabled for the deployment
(
AttestoPhoenix.Config.client_id_metadata_enabled?/1), and - the
client_idis a well-formed CIMD URL (Attesto.ClientIdMetadata.client_id_url?/1).
An opaque (non-URL) client_id, or any client_id when the feature is off,
is left to the host's :load_client registry exactly as before - CIMD never
changes the resolution of a registered client.
The resolved client
resolve/2 returns the normalized, string-keyed metadata map
Attesto.ClientIdMetadata.validate_document/2 produced (carrying at least
client_id and redirect_uris). It is not the host's opaque client value,
so the host's :client_id / :client_redirect_uris / :client_jwks
callbacks do not apply to it; the accessors here
(client_id/1, redirect_uris/1, jwks/1) read the document directly, and
the calling endpoint uses them in place of the host callbacks for a CIMD
client. A resolved CIMD client authenticates only as a public client
(none + PKCE) or with private_key_jwt; the no-symmetric-secret rule the
document validation enforces guarantees client_secret_* can never apply.
redirect_uri policy (RFC 9700 + draft §2)
RFC 9700 requires the request redirect_uri to exact-match one of the
document's redirect_uris; the calling endpoint performs that match through
the same Attesto.AuthorizationRequest path it uses for a registered client,
feeding it redirect_uris/1 as the registered set. The draft additionally
permits requiring the redirect_uri to be same-origin (scheme + host + port)
with the client_id URL; same_origin_redirect_uri?/2 is that check, applied
by the authorization endpoint when :require_same_origin_redirect_uri is set
(the default).
Summary
Types
A resolved CIMD client: the normalized, string-keyed metadata map
Attesto.ClientIdMetadata.validate_document/2 returns.
Functions
Returns true iff client_id must be resolved through CIMD for config:
the feature is enabled and client_id is a well-formed CIMD URL.
The CIMD client's client_id - the URL the document was fetched from and is
bound to (Attesto.ClientIdMetadata.validate_document/2 guarantees the
document's client_id equals it).
The CIMD client's verification keys for private_key_jwt client
authentication (RFC 7523 / OIDC Core §9), taken from the document's inline
jwks (preferred) or its jwks_uri. Returns nil when the document carried
neither, which makes private_key_jwt impossible for the client (it then
authenticates only as a public client).
Whether uri is a loopback redirect URI under mode (RFC 8252 §7.3, plus
the localhost name under
:exact_allow_loopback_port_including_localhost).
Whether the document declares at least one redirect URI that is loopback
under mode (http://127.0.0.1/... or http://[::1]/... under
:exact_allow_loopback_port; those plus http://localhost/... under
:exact_allow_loopback_port_including_localhost).
The CIMD client's registered redirect URIs (RFC 9700), used by the
authorization endpoint as the exact-match set in place of the host's
:client_redirect_uris callback. Document validation guarantees a non-empty
list of strings.
Resolve a CIMD client_id URL into its normalized client metadata map.
Returns true iff redirect_uri is same-origin (scheme, host, and port) with
the CIMD client_id URL (draft §2's optional same-origin tightening).
The scopes the CIMD document declares, as a list.
Types
@type client() :: map()
A resolved CIMD client: the normalized, string-keyed metadata map
Attesto.ClientIdMetadata.validate_document/2 returns.
Functions
@spec cimd_client_id?(term(), AttestoPhoenix.Config.t()) :: boolean()
Returns true iff client_id must be resolved through CIMD for config:
the feature is enabled and client_id is a well-formed CIMD URL.
This is the single gate the endpoints consult before reaching for the
resolver, so an opaque client_id (or any client_id while the feature is
disabled) is never sent to the network and always flows through the host's
:load_client registry.
The CIMD client's client_id - the URL the document was fetched from and is
bound to (Attesto.ClientIdMetadata.validate_document/2 guarantees the
document's client_id equals it).
The CIMD client's verification keys for private_key_jwt client
authentication (RFC 7523 / OIDC Core §9), taken from the document's inline
jwks (preferred) or its jwks_uri. Returns nil when the document carried
neither, which makes private_key_jwt impossible for the client (it then
authenticates only as a public client).
@spec loopback_redirect_uri?(term(), Attesto.RedirectURI.matching()) :: boolean()
Whether uri is a loopback redirect URI under mode (RFC 8252 §7.3, plus
the localhost name under
:exact_allow_loopback_port_including_localhost).
Delegates the decision to Attesto.RedirectURI so "what counts as loopback"
has exactly one definition: a URI is loopback iff the matcher in mode would
treat it as one. Under the default :exact_allow_loopback_port,
http://localhost/... is deliberately not loopback (§8.3 — the literal IP is
required), so a document declaring only localhost gets no port flexibility
unless the deployment opted into the wider mode.
@spec loopback_redirect_uris?(map(), Attesto.RedirectURI.matching()) :: boolean()
Whether the document declares at least one redirect URI that is loopback
under mode (http://127.0.0.1/... or http://[::1]/... under
:exact_allow_loopback_port; those plus http://localhost/... under
:exact_allow_loopback_port_including_localhost).
This is how a CIMD client says "I am an installed app". CIMD has no
application_type member — it inherits the dynamic-registration metadata
registry by reference, and whether that carries OpenID Connect's
application_type is an open question on the draft's own tracker — so the
only native signal available is the shape of the URIs the document itself
declares. Those are validated document content rather than a claimed
attribute, which makes them the sounder thing to key on regardless.
mode is the loopback matching mode the deployment resolved
(AttestoPhoenix.Config.native_app_loopback_matching/1); the predicate must
read the native signal with the same mode the match will use, or the two
disagree about the same URI.
The CIMD client's registered redirect URIs (RFC 9700), used by the
authorization endpoint as the exact-match set in place of the host's
:client_redirect_uris callback. Document validation guarantees a non-empty
list of strings.
@spec resolve(String.t(), AttestoPhoenix.Config.t()) :: {:ok, client()} | {:error, AttestoPhoenix.ClientIdMetadata.Resolver.error()}
Resolve a CIMD client_id URL into its normalized client metadata map.
Delegates to AttestoPhoenix.ClientIdMetadata.Resolver.resolve/2. The caller
is expected to have gated on cimd_client_id?/2 first; the resolver
re-validates the URL grammar regardless, so a non-CIMD client_id reaching
here still fails closed. Returns {:ok, client} or {:error, reason} (a
fetch, decode, validation, or host-policy failure - never cached).
Returns true iff redirect_uri is same-origin (scheme, host, and port) with
the CIMD client_id URL (draft §2's optional same-origin tightening).
The port comparison uses each URI's effective port, so an explicit default
port and an omitted one compare equal. A redirect_uri that does not parse as
an absolute URL with a host is not same-origin.
The scopes the CIMD document declares, as a list.
draft-ietf-oauth-client-id-metadata-document-01 §7 carries the RFC 7591 §2
client-metadata field set, in which scope is an OPTIONAL, space-delimited
string. A document that omits it declares no scopes, so this returns [] — an
empty declared set, never a missing key. A CIMD client therefore has no
registered scope cap of its own; what an empty declared set grants is host
policy (typically the scopes the resource owner consents to).
This is the value AttestoPhoenix.AuthorizationServer.Token exposes to a host
:authorize_scope policy as client.scopes, so a callback written for a
registered client's scope list reads a CIMD client as an empty set instead of
raising KeyError.