Repository navigation
feat(oauth): accept client ID metadata documents beside registration - #1152
Conversation
A client_id that is an https URL now names the client's metadata document (draft-ietf-oauth-client-id-metadata-document-02, MCP 2026-07-28). An OpenIddict handler keeps one ordinary application row per URL, built by the same rules as DCR, and refetches it after its Cache-Control lifetime. The fetcher resolves once, refuses special-use addresses, connects to the vetted address, and follows no redirect, under a size cap, a deadline and a shared fetch budget. Consent and the sign-in preview show the verified domain. The Connected apps disconnect takes the client id as a query parameter, because a URL id cannot travel in a path segment. Refs #1148
Three findings from the security review and the mutation pass: - A drained global fetch budget no longer locks out apps already in use. An expired copy that once passed every check stands in until a fetch is possible again; only a client never stored is refused. - A concurrent first request that loses OpenIddict's duplicate client_id check now accepts the other request's copy instead of answering 400. - Consent Details names the full document URL, because a domain that hosts files for anyone verifies nothing about who published one. Refs #1148
Review round 1 (Codex GPT-6 Astra) and the commit security review: - A body cut short after the headers (HttpIOException) is a failed fetch and an OAuth refusal, not a server error. - 2620:4f:8000::/48 (AS112) joins the refused IPv6 blocks; the table is checked against both IANA special-purpose registries. - A lone surrogate in any string field of a document is invalid metadata, not a server error. - A deterministic test pins the duplicate-client race: the second request uses the first request's stored copy. - The stale copy used while the fetch budget is spent is bounded to 24 hours past its expiry, so it cannot keep a removed redirect alive. - tools/oauth/mutation-check.sh gains 47 metadata-document mutants and runs the two Docker-free test classes in its baseline. Refs #1148
The iss refusal test now pins OpenIddict's reason, so letting the refused document through unstored is caught. Two mutant rows no longer declare an unused variable, two declare xUnit's real message, and the baseline floor is the suite's measured 261. All 47 cimd mutants are killed. Refs #1148
|
Review round 1 from Codex GPT-6 Astra (head F1 (P2), interrupted body. Fixed in F2 (P3), AS112 IPv6. Fixed in F3 (P2), lone surrogate. Fixed in F4 (P2), duplicate-client race. Fixed in F5 (P3), mutation proof. Fixed in One more change in |
… pure duplicate Review round 2 (Codex GPT-6 Astra): - The stale-copy fallback is gone. Failed refreshes spend the URL's budget, so draining it would revive the redirect URIs of a document its publisher removed. While the budget is spent, an app whose copy expired waits. - A losing concurrent insert adopts the winner's row only when OpenIddict's sole refusal is the duplicate client id; an iss parameter in the version it fetched is still refused. - The mutation runner no longer guards or restores UserEndpoints.cs, which no mutant targets. Refs #1148
Review by Claude Fable 5.1 at 7429d88: - The idempotency fingerprint covers a write's query string, so two Disconnects of different apps under one key conflict instead of the second replaying the first's 204 and leaving its app connected. A write without a query hashes exactly as before. - A metadata request without response_type=code or an S256 code challenge is refused before any fetch. OpenIddict's own parameter checks run only after its first client lookup in ValidateAuthentication, so the handler cannot move after them. - A client someone approved skips the global fetch budget, which strangers can drain, and still spends its per-URL budget; its copy is refreshed, never served stale. - The mixed DNS answer test covers both orders, and a guard ties the URL cap to the ClientId and audit columns in the EF model. - The decision record states the first lookup, that Enabled=false refuses and never revokes, the column behind the cap, and the anonymous row bound. Refs #1148
2d36aa5 exempted a client with a valid authorization from the global fetch budget. Anyone with an account can approve clients of their own, so the exemption let them fetch outside every global bound (commit security review). Approved clients now spend a separate global budget of the same size, which strangers cannot drain, so total fetches stay bounded. The approval check goes through OpenIddict's authorization manager rather than Access's table, which the compatibility-exception guard refused. Refs #1148
|
This covers Codex GPT-6 Astra's round 2 (head Codex GPT-6 Astra, round 2R2-F1 (P1), a drained budget revived refused metadata. Fixed in R2-F2 (P2), duplicate recovery hid other validation failures. Fixed in R2-F3 (P3), Claude Fable 5.1F1 (P2), Disconnect lost its idempotency fingerprint. Fixed in F2 (P3), the fetch came before the cheap checks. Fixed in F3 (P3), the cap named the wrong column. Fixed in F4 (P3), F8 (P3), the mixed-answer test covered one order. It is now a theory over both orders. F9 (P3), ponytail notes. Done: the duplicated size message is one field, and F10 (P3), the junk-row bound. The record now states it: about 86,400 rows a day from three addresses, held to about one day of that rate by the purge of unapproved rows. Forward note: the global-budget exemption. Built, but changed. As an outright exemption from the global budget ( Left as known limits, unchanged: F7, the |
|
Final review by Claude Fable 5.1 at head 359d24c: 0 P1, 0 P2, 2 P3. Confirmed:
Known limits, recorded here and not fixed:
Also noted: a disabled user's authorizations count as approved for the fetch budget until an Owner disconnects them. This affects availability only. Disabling the user already refuses their tokens. The review loop for this PR is complete. Codex GPT-6 Astra ran two rounds, then Claude Fable 5.1 ran an independent round and a final check. |












An app can now connect to Cluckwork by naming the https URL of a JSON document it publishes as its
client_id. This is a client ID metadata document (draft-ietf-oauth-client-id-metadata-document-02). The MCP authorization specification, revision 2026-07-28, recommends these documents and deprecates dynamic client registration (DCR). Before this change, a client that supports only metadata documents could not connect. DCR keeps working unchanged beside them.For the person approving an app, the consent screen and the sign-in preview now show Verified domain: example.com instead of Unverified app for these clients. Details names the full document URL and says what Cluckwork checked: the details came from whoever controls that address. It names the URL, not only the domain, because some websites let anyone publish a file. The name is still the app's own choice. When the redirect goes to this computer, Details adds that the domain cannot vouch for which program receives the approval.
Closes #1148
How it works
Seam. An OpenIddict handler on the authorization request runs just before OpenIddict looks the client up. For a
client_idthat starts withhttps://, it makes sure a fresh application row exists. Every otherclient_idgoes to the registered client as before. I rejected a custom application store that fetches insideFindByClientIdAsync. With that design the token endpoint would fetch too, and a second in-memory copy of the client would sit beside the row the foreign keys need anyway. The decision record compares both designs.Storage. Each document URL gets one ordinary OpenIddict application row, built by the same
ToDescriptorrules as a DCR client. The expiry is stored in OpenIddict'sPropertiescolumn, so this PR has no migration. The row is the cache, and it lives in Postgres, so every replica reads the same copy (#271). Connected apps, audit provenance (#800), the farm switch (#1146), the #796 checks and the purge sweep read the row and the token claims, so they treat these clients like any other. A metadata client nobody approves is purged after a day like a registration, and its next use fetches it again. Only the authorization request fetches; the token endpoint redeems a code that was issued against the row.The fetcher, which is the SSRF boundary. It follows these rules:
2000::/3, minus its special blocks. The fetcher then connects to the address it checked, so DNS rebinding has nothing to change.2620:4f:8000::/48(AS112) missing, and it is now refused.IPNetwork.Containsplaces::ffff:10.0.0.1inside2000::/3.200andapplication/json.response_type=codeand an S256code_challenge. OpenIddict's own parameter checks run only after its first client lookup inValidateAuthentication, so the handler checks these itself first.Content-Lengthfrom the headers, and it enforces the cap while a chunked body streams.Cache-Controllifetime, between 5 minutes and 24 hours. Errors and invalid documents are never stored.What a document may say. The document's
client_idmust equal its URL, and it must not carry a client secret. Everything else must pass the #797 rules: a public client, the authorization code grant only, and https or loopback redirects. The rules also sanitise the name. A lone surrogate in any string field is invalid metadata, not a server error.Races. Two first requests for the same URL can both find no row. The loser meets OpenIddict's duplicate-client check, the unique index or the concurrency token, then re-reads the row in a fresh scope and uses the winner's copy. That recovery applies only when the duplicate client id is OpenIddict's sole refusal. A version the loser fetched that also breaks a rule, such as an
issparameter in a redirect URI, is still refused.Which URLs qualify. The URL must be canonical ASCII https with a path, and it may not have a query. It is capped at 100 characters, because
OpenIddictApplications.ClientIdand the audit column are both 100 wide.Discovery. Discovery advertises
client_id_metadata_document_supported: true.Changes a reviewer should check
DELETE /me/connected-appsandDELETE /users/{id}/connected-appsnow take?clientId=instead of a path segment. A URL client id contains/, and Kestrel and proxies do not agree on how to decode an encoded slash in a path. The SPA has one caller for each route, and both are updated. I found no other callers in k6, Playwright or the docs.IdempotencyMiddlewarenow folds a write's non-empty query string into its request hash. Otherwise two Disconnects of different apps under one key would replay the first app's 204 and leave the second connected. Reusing the key now gets a 409. Writes without a query hash exactly as before, and only the two Disconnect calls send one.Cluckwork.Infrastructure.OAuth. The DCRRegisterendpoint and the handler shareToDescriptor. An endpoint that names an Access type outside its contract fails CW1004, and contracts can only live in Domain and Application.OAuth:ClientMetadata:Enableddefaults to true. A deployment without outbound https turns it off. Discovery then stops advertising support, and an httpsclient_idis refused before any fetch.deploy/.env.exampledocuments this.OAuth:ClientMetadata:PrivateHostslists exact host names that may resolve to a private network, such as a test stack. Loopback, link-local and multicast stay refused for these hosts too.Docs
docs/decisions/1148-client-id-metadata-documents.mdis new. A rule insrc/AGENTS.mdpoints to it.GLOSSARY.mdgains Verified domain.Verification
ClientMetadataFetcherTests(no database) runs every fetcher rule over real TLS to a loopback Kestrel. The tests reach it only through a scripted resolver and dialler. They cover each refused address class, a mixed answer, the rebinding pin,PrivateHosts, a redirect, status codes, content types, the streamed and declared size caps, an interrupted body, a slow body, a slow connect, the request headers, the lifetime clamp and the address table.ClientMetadataTests(no database) pins the URL shape, the length cap, the verified domain and the document rules against literal inputs, including a lone surrogate in each string field.OAuthFailClosedTests.ClientMetadataDocuments(Postgres) covers the flow with a metadata client: connect, list, audit and disconnect, the farm switch, consent and preview, the cache, refresh, unusable documents stored nowhere, OpenIddict'sissrefusal, a malformed request refused before any fetch, the per-URL and both global budgets, an approved app refreshed past a drained strangers' budget, a removed document that a drained budget does not revive, the column lengths behind the URL cap, the insert races (including one where the losing request fetched an invalid version), discovery, turning the feature off, and the purge.OAuthFailClosedTests.ConnectedAppspins the Disconnect fingerprint.OAuth|ClientMetadata) passes 272 of 272, the idempotency suites pass, the architecture guards pass (383), and the web unit suite passes (3,776).7429d88e, after review round 1, and again on the head this body was last updated for.main, which measured 1,952.67 KiB at e4ec534.Known limits
The reviews raised these, and they are left unchanged on purpose:
PrivateHostsentry admits every port on that host. Only hosts the operator lists are reachable, and the list exists for test stacks.Mutation proof
tools/oauth/mutation-check.shcarries 52cimd-*mutants, and its baseline filter includes the two Docker-free classes. Each mutant is one exact edit. The runner rebuilds, runs the one named test, and reads the verdict from the TRX file. All 52 ran ata3a56fd6, and all were killed. One was killed by a different assertion than its row declared: with every client counted as approved, the strangers' drain empties the approved budget too. Its row now declares that assertion, and its rerun was killed. Baseline and restore were green at 272 of 272. Earlier rounds produced the same results and are summarised in the review replies.cimd-mixed-answer-admittedClientMetadataFetcherTests.MixedAnswer_IsRefused_WhicheverAddressComesFirstcimd-mapped-admittedClientMetadataFetcherTests.SpecialUseAddress_IsRefused_BeforeAnyConnectioncimd-v6-not-allow-listClientMetadataFetcherTests.SpecialUseAddress_IsRefused_BeforeAnyConnectioncimd-private-v4-admittedClientMetadataFetcherTests.SpecialUseAddress_IsRefused_BeforeAnyConnectioncimd-link-local-admittedClientMetadataFetcherTests.SpecialUseAddress_IsRefused_BeforeAnyConnectioncimd-cgnat-admittedClientMetadataFetcherTests.SpecialUseAddress_IsRefused_BeforeAnyConnectioncimd-rebindingClientMetadataFetcherTests.Connection_IsPinnedToTheVettedAddresscimd-private-hosts-any-nameClientMetadataFetcherTests.PrivateHosts_AdmitsOnlyTheNamedHost_OnAPrivateNetworkcimd-private-hosts-loopbackClientMetadataFetcherTests.PrivateHosts_AdmitsOnlyTheNamedHost_OnAPrivateNetworkcimd-redirect-followedClientMetadataFetcherTests.Redirect_IsNotFollowedcimd-any-2xxClientMetadataFetcherTests.OnlyOk_IsAcceptedcimd-content-type-uncheckedClientMetadataFetcherTests.OtherContentType_IsRefusedcimd-stream-uncappedClientMetadataFetcherTests.StreamedBody_IsCappedWhileReadingcimd-declared-length-uncheckedClientMetadataFetcherTests.DeclaredLength_OverTheCap_IsRefusedBeforeReadingcimd-body-no-deadlineClientMetadataFetcherTests.SlowBody_StopsAtTheDeadlinecimd-no-deadlineClientMetadataFetcherTests.SlowConnect_StopsAtTheDeadlinecimd-cookies-keptClientMetadataFetcherTests.Request_CarriesNoCookie_AndAnHonestUserAgentcimd-proxy-usedClientMetadataFetcherTests.Handler_FollowsNoRedirect_UsesNoProxy_KeepsNoCookiescimd-lifetime-unclampedClientMetadataFetcherTests.Lifetime_FollowsCacheControl_WithinTheBoundscimd-no-cache-kept-longClientMetadataFetcherTests.Lifetime_FollowsCacheControl_WithinTheBoundscimd-client-id-caseClientMetadataTests.Document_NamingAnotherClientId_IsRefusedcimd-secret-allowedClientMetadataTests.UntrustedDocument_IsRefusedcimd-duplicate-keysClientMetadataTests.UntrustedDocument_IsRefusedcimd-url-noncanonicalClientMetadataTests.OtherForms_AreRefusedcimd-url-queryClientMetadataTests.OtherForms_AreRefusedcimd-url-ip-literalClientMetadataTests.OtherForms_AreRefusedcimd-url-lengthClientMetadataTests.Url_LongerThanTheColumn_IsRefusedcimd-cache-ignoredOAuthFailClosedTests.StoredCopy_IsReused_UntilItExpirescimd-never-refetchedOAuthFailClosedTests.Refetch_AppliesTheNewDocumentcimd-update-skippedOAuthFailClosedTests.Refetch_AppliesTheNewDocumentcimd-per-url-budgetOAuthFailClosedTests.FailingUrl_IsFetchedOnlyWithinItsBudgetcimd-global-budgetOAuthFailClosedTests.ManyUrls_ShareOneGlobalBudgetcimd-race-unhandledOAuthFailClosedTests.ConcurrentFirstRequests_StoreOneRowcimd-issuer-in-redirect-storedOAuthFailClosedTests.DocumentWithAnIssuerInItsRedirect_IsRefused_AndNotStoredcimd-disabled-still-fetchesOAuthFailClosedTests.TurnedOff_NeitherAdvertisesNorFetchescimd-not-advertisedOAuthFailClosedTests.Discovery_AdvertisesMetadataDocumentscimd-handler-unregisteredOAuthFailClosedTests.MetadataClient_ConnectsListsAuditsAndDisconnects_LikeAnyOthercimd-consent-domain-hiddenOAuthFailClosedTests.Consent_AndPreview_NameTheVerifiedDomaincimd-preview-domain-hiddenOAuthFailClosedTests.Consent_AndPreview_NameTheVerifiedDomaincimd-disconnect-by-pathOAuthFailClosedTests.MetadataClient_ConnectsListsAuditsAndDisconnects_LikeAnyOthercimd-interrupted-body-escapesClientMetadataFetcherTests.InterruptedBody_IsUnreachablecimd-as112-v6-admittedClientMetadataFetcherTests.SpecialUseAddress_IsRefused_BeforeAnyConnectioncimd-lone-surrogate-escapesClientMetadataTests.LoneSurrogate_InAnyStringField_IsInvalidMetadatacimd-client-id-uncheckedClientMetadataTests.Document_NamingAnotherClientId_IsRefusedcimd-duplicate-client-refusedOAuthFailClosedTests.SecondRequest_MeetingTheFirstsCopy_UsesItcimd-stale-fallback-restoredOAuthFailClosedTests.RemovedDocument_IsNotRevived_ByDrainingTheBudgetcimd-mixed-validation-recoveredOAuthFailClosedTests.SecondRequest_WithAnInvalidDocument_IsRefused_DespiteTheFirstsCopycimd-idempotency-query-ignoredOAuthFailClosedTests.Disconnect_ReusingAKeyForAnotherApp_IsAConflict_NotAReplaycimd-response-type-fetchedOAuthFailClosedTests.MalformedRequest_IsRefused_BeforeAnyFetchcimd-pkce-fetchedOAuthFailClosedTests.MalformedRequest_IsRefused_BeforeAnyFetchcimd-approved-shares-strangers-budgetOAuthFailClosedTests.ApprovedApp_IsRefreshed_PastADrainedGlobalBudgetcimd-everyone-gets-approved-budgetOAuthFailClosedTests.ApprovedApp_IsRefreshed_PastADrainedGlobalBudget