Skip to content

Accept Client ID Metadata Documents as client IDs - #284

Merged
longsleep merged 2 commits into
libregraph:masterfrom
longsleep-io:upstream/8-cimd
Oct 14, 2026
Merged

longsleep merged 2 commits into
libregraph:masterfrom
longsleep-io:upstream/8-cimd

Conversation

@longsleep

Copy link
Copy Markdown
Collaborator

With a Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document-02), a client uses the https URL of a JSON document it publishes about itself as its client_id. The authorization server fetches the document instead of keeping a registration. Such a client needs neither a pre-registration nor dynamic client registration, which removes the last thing a user has to enter when dynamic registration is off.

It is off unless enabled. The new repeatable --client-id-metadata-document-host lists the hosts whose documents lico fetches, and listing one is what enables it.

Changes

Host list

  • An exact host, with its port if it has one, matches as listed. *.example.com matches subdomains, and * matches any public host.
  • Wildcards never match an IP address, a single-label name, an explicit port or a local-only name such as localhost, local, internal, home.arpa, lan or test.
  • With at least one host listed, the metadata announces client_id_metadata_document_supported.

Fetching (identity/clients/cimd.go)

  • The client_id URL is checked against section 3 of the draft before any network access.
  • One GET with a 5 second timeout. No redirects followed, only a 200 with a JSON content type is taken, and at most 5 KB is read.
  • The dialer checks the address it actually connects to against the RFC 6890 special-use blocks, so a listed name which resolves to an internal address is refused too. No proxy is used, because a proxy would bypass that check.
  • Documents are cached as their Cache-Control says: 5 minutes by default, bounded to between 1 minute and 24 hours. Concurrent lookups share one fetch.
  • Errors and invalid documents are never cached. After a network error, a 5xx or a 429, the last good document is served for up to 24 hours past its expiry, because the token endpoint looks the client up on every refresh.

Validation

  • The document's client_id must equal its URL. It carries no secret, and token_endpoint_auth_method must be absent or none.
  • Unknown grant types are dropped rather than refused; only a document without authorization_code is refused. response_types must include code when present.
  • Redirect URIs are either https on the client_id's own host (a web client) or loopback (a native client), never both. The redirect URI checks of dynamic registration are shared with this.
  • The resulting client is public, must use PKCE and is never trusted.
  • Lookup order: static registry first, so an operator can still pre-register a URL client_id, then the metadata document, then dynamic clients.

Consent screen

  • A client nobody vouches for, dynamically registered or identified by a metadata document, is marked unverified. A metadata document client is named by the host of its client_id, which it cannot fake, as section 8.5 of the draft asks. Its self-chosen client_name is shown only beside the host.
  • For unverified clients, the screen states where the user is redirected. A local destination comes with a warning to continue only when the user started the sign-in from that application.
  • When the request names a registered resource, the screen names it: "app.example.com wants to use to:". Registered clients without a resource see the previous screen exactly.
  • All six shipped locales are translated.

Tests and docs

  • cimd_test.go covers the URL rules, the host list, the address check, and the fetcher against a TLS test server: caching, a shared fetch, stale serving and its limit, refusals.
  • TestHelloNamesTheResource and Vitest cases cover the consent screen.
  • Tested against a live public metadata document host: the metadata announces support, the host's documents pass the client lookup, and a foreign redirect URI or an unlisted host is refused.
  • The README and API spec document the flag, the client_id form, and the new consent fields.

With Client ID Metadata Documents
(draft-ietf-oauth-client-id-metadata-document-02) a client uses the
https URL of a JSON document it publishes about itself as its
client_id, and lico fetches that document instead of keeping a
registration. The authorization server then needs neither a
pre-registration nor dynamic client registration for such a client,
and clients use it when the authorization server announces support. It
removes the last thing a user has to enter when dynamic registration
is off.

The new repeatable --client-id-metadata-document-host enables it. An
exact host, with its port if it has one, matches as listed,
*.example.com matches subdomains and * any public host. Wildcards never
match an address, a single-label name, an explicit port or a name only
meaningful inside a local network, such as localhost, local, internal,
home.arpa, lan or test. There is no separate switch: listing a host
enables the feature, and the metadata then announces
client_id_metadata_document_supported, which together with none among
the token endpoint auth methods is what lets a public client choose it.

identity/clients/cimd.go checks the client_id URL against section 3 of
the draft before any network access, being https with a path and
without userinfo, query, fragment, dot segments or a trailing dot, and
against the host list. It then makes one GET with Accept
application/json and a 5 second timeout, follows no redirect (section
5), takes only a 200 with a JSON content type and reads at most 5 KB
(section 8.7). The dialer checks the address it actually connects to
against the RFC 6890 and IANA special-use blocks (section 8.6), so a
listed name resolving inward is refused as well. No proxy is used,
because a proxy would dial past that check.

The document's client_id must equal the URL, and it may carry neither
client_secret nor client_secret_expires_at. token_endpoint_auth_method
must be absent or none: no shared secret is possible and private_key_jwt
is not implemented, so anything else is refused rather than downgraded.
Clients list grant types lico does not implement, such as
urn:ietf:params:oauth:grant-type:jwt-bearer, and refusing them would
lock such clients out, so grant types are read as a request per RFC
7591 section 3.2.1: unknown ones are dropped and only a document without
authorization_code is refused. response_types must include code when
present. Every redirect URI which is not loopback must be https on the
client_id's host. Loopback redirects alone make a native client, https
alone a web client, and a mix is refused. The redirect URI checks of
Register are extracted into prepareRedirectURIs and run here too.

The resulting registration is public, requires PKCE and is never
trusted. Its Name is the host of the client_id, which nobody can fake,
and the document's client_name travels beside it as ClaimedName.
Details gains unverified, for dynamic and metadata document clients
alike, and claimed_name, for the consent screen to use in the next
change.

Documents are cached as their Cache-Control says, parsed with
github.com/pquerna/cachecontrol, which was already in the module graph
and is now a direct dependency. The default is 5 minutes, bounded to
between 1 minute and 24 hours. Concurrent lookups share one fetch,
detached from the caller's context. Errors and invalid documents are
never cached (section 5.2). A definite answer, a 4xx or an invalid
document, drops the cached entry. A network error, a 5xx or a 429 keeps
serving the last good document for up to 24 hours past its expiry,
because the token endpoint looks the client up on every refresh.

Registry.Lookup and Get try the static registry first, so an operator
can still pre-register a URL client_id, then a metadata document for an
https client_id when enabled, then dynamic clients. A failed fetch is
the answer, never the implicit trust of a redirect to the issuer.
NewRegistry takes the host list.

The README lists the draft among the standards and describes it in the
client registration section, with app.example.com as the example host.
The API spec lists the draft in its info section, documents client_id
values which are metadata document URLs, adds such clients to those
which must use PKCE, and gains the metadata field.

cimd_test.go covers the client_id URL rules, the host list including
wildcards and local names, and the address check. A web and a native
client document, in the shapes public clients publish, are fixtures
alongside documents to be refused. The fetcher runs against a TLS test
server for caching, a shared concurrent fetch, stale serving while the
host fails and its limit, a 404 dropping the entry, uncached errors, a
redirect, an oversized and a non-JSON document and an unlisted host
never fetched, and the production client is shown to refuse a loopback
address. A registry test drives Lookup with loopback redirects of a
native client.

Verified against a live public metadata document host listed with
--client-id-metadata-document-host: the metadata announces support,
both of its client IDs pass client lookup at the token endpoint and
fail only on the bogus code with invalid_grant, a foreign redirect URI
and an unlisted host fail the client lookup, and the host's max-age=300
sets the cache lifetime.
The previous change made two kinds of client possible which nobody
vouches for, dynamically registered ones and those described by a
Client ID Metadata Document, and requests can be bound to a protected
resource. The consent screen did not say who was asking or for what,
so it now does.

The web app passes the authorization request's resource to hello. The
identifier looks it up in the resource registry, which it now takes
as an optional manager, and answers resource with id and name on a
consent answer. The sentence becomes "app.example.com wants to use
<resource> to:" through the new key
konnect.consent.messageWithResource, falling back to the id when the
registration has no name. That name comes from the operator's
registration, so unlike the client's it is one the server vouches for.
An unknown resource never reaches the screen, because the
authorization endpoint refuses it with invalid_target first.

A client nobody vouches for chooses its own name, so that name alone
tells the user nothing. A client marked unverified is therefore named
by its display_name, which for a metadata document client is the host
of its client_id, the host its metadata document and its redirect URIs
live on, as section 8.5 of the draft asks. The document's
self-asserted claimed_name is shown beside it as "It identifies itself
as Example App.", never instead of it, and not at all when it only
repeats the host.

For such clients the redirect destination is stated on the page rather
than behind the info icon. A local destination, loopback or the
private-use scheme of a dynamically registered native client, is
stated as an application on this device and comes with a warning to
continue only when the user started the sign-in from that application,
since any application on the device can listen on localhost.
Registered clients keep the previous screen exactly.

The consent sentence naming the client is rendered with
react-i18next's <Trans> component, whose translations address the
client name element by position. Both variants of it, with and without
a resource, are now built in one const ahead of the JSX, so a single
biome-ignore keeps the formatter from reflowing them. The resource
variant carries explicit defaults, because i18next-parser would
otherwise extract the JSX expression literally instead of the
{{resource}} interpolation. All six shipped locales are translated, and
the catalogs were regenerated with make pot and merge, leaving no fuzzy
entries.

The API spec documents hello's resource request member, the resource
answer member as ResourceDetails, and unverified and claimed_name on
ClientDetails, and says that dynamically registered clients are shown
as unverified on the consent screen.

TestHelloNamesTheResource expects the resource named in canonical
form, and none without a resource or with an unknown one; it fails
without the change. Vitest cases cover the unverified host and claimed
name, the stated destination, the local warning for loopback and
private-use schemes, and the resource sentence with and without a
name. Every locale renders the resource and unverified notices without
unmapped indices or unresolved placeholders. The built web app served
by licod was also checked with stubbed hello answers for a web and a
native unverified client.
@longsleep
longsleep merged commit dbb342c into libregraph:master Oct 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant