fix: preserve CIMD registration provenance across SDK issuer binding - #2287
Conversation
…2242) `BaseOAuthClientProvider.saveClientInformation` treated every save without an explicit `registrationKind` as DCR. The SDK reaches it from three places in `auth()` and only one is a dynamic registration, so the issuer-binding write overwrote the `cimd` provenance our own pre-registration had just stored — and Connection Info reported `Dynamic (DCR)` for a connection that never issued a `POST /oauth/register`. Recover the kind instead: a `client_id` equal to the configured `clientMetadataUrl` is CIMD (which also covers the SDK's own CIMD write), and otherwise a stored registration with the same `client_id` carries its recorded kind forward. Matching on `client_id` keeps stale CIMD provenance from leaking onto a later DCR registration for the same server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟢 Approval recommended
All reviewed changes are covered by focused unit and end-to-end tests, with no unresolved issues.
Pull request overview
Fixes CIMD registrations being incorrectly relabeled as DCR during SDK issuer binding.
Changes:
- Preserves registration provenance using client ID and stored state.
- Adds unit coverage for provenance resolution.
- Verifies CIMD provenance across OAuth transports.
File summaries
| File | Description |
|---|---|
core/auth/providers.ts |
Preserves CIMD provenance during SDK saves. |
clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts |
Verifies end-to-end CIMD state. |
clients/web/src/test/core/auth/providers.test.ts |
Covers provenance resolution and fallback behavior. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
Inferring provenance from client_id can mislabel a genuine dynamic registration as CIMD.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot review (#2287): RFC 7591 §3.2 leaves a dynamically issued `client_id` opaque, so `client_id === clientMetadataUrl` is not on its own proof that CIMD ran — an authorization server could in principle mint that value from `POST /register`. Narrow the claim to a conjunction, whose decisive term is a registration we recorded as CIMD ourselves rather than an inference about what the AS returned: CIMD must be configured for this connection, the incoming `client_id` must be exactly that metadata-document URL, and the registration already stored under that id must be recorded as `cimd`. Everything else falls through to `dcr`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 2 — 1 comment, addressed in c8c050b
RFC 7591 §3.2 does leave a dynamically issued The mechanism cannot be carried from the SDK call site as suggested: SDK v2's What is available is a registration we recorded ourselves, so the rule is now a conjunction rather than an inference from
(3) is the decisive term and is a recorded fact, not a guess about what the AS returned. Both scenarios in the comment now fall through to I also dropped the branch that preserved a stored Tests reworked to match. Five negative cases now pin each way out to
Also in this round: before/after screenshots of a live CIMD login are now in the PR body — same server, same Client ID, no registration request in either run,
|
There was a problem hiding this comment.
🟡 Changes recommended
The unresolved multi-issuer CIMD provenance issue can still produce incorrect DCR classification.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot review (#2287): scoping the stored-CIMD check to the incoming issuer alone mislabels the SDK's own CIMD write for a second authorization server. SEP-2352 keys registrations per AS, so the first binding promotes the unkeyed CIMD entry into issuer A's slot and clears the fallback; `ensureCimdClientRegistration` then early-returns on its ctx-less read, and the save under issuer B finds nothing recorded for B. Check the issuer slot and then the server's active registration, so a second issuer stays CIMD while a `client_id` the AS minted itself still falls through to `dcr`. Covered by two tests driving a real `OAuthStorageBase` through the A → B sequence, since the behaviour under test is how storage promotes and clears slots rather than anything a mock would express. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 3 — 1 comment, addressed in f3f68c7
The trace in the comment is exactly right:
The lookup is no longer scoped to the incoming issuer. It tries Round 2's narrowing is unaffected: an AS that mints its own Both cases are covered by tests driving a real Mutation-checked, each guard independently:
Unit suite 44 passed, CIMD OAuth E2E 34 passed. A full |
There was a problem hiding this comment.
🟡 Changes recommended
CIMD provenance must be issuer-bound to avoid misclassifying a real cross-issuer DCR.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot review (#2287): the active-issuer fallback added last round could copy issuer A's CIMD provenance onto a real dynamic registration for issuer B, since RFC 7591 §3.2 permits B to mint the same opaque URL as its `client_id`. Fix it at the source rather than in the resolver. `ensureCimdClientRegistration` now runs discovery first and records the registration against the issuer it just discovered, having confirmed *that* AS advertises `client_id_metadata_document_supported`. Its "already registered?" check moves after discovery and is keyed by that issuer — read ctx-less it resolved through the active issuer and early-returned for every later one, which is what forced the cross-issuer fallback in the first place. `resolveSdkRegistrationKind` is therefore issuer-scoped again, with no fallback. A second AS behind one resource now gets its own determination: CIMD when it advertises CIMD, `dcr` otherwise — including when it mints the metadata URL as its own `client_id`. The cost is a discovery round trip per connect attempt rather than only the first; noted at the call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 4 — 1 comment, addressed in 4c6a9fb
I fixed it at the source instead of in the resolver.
With provenance issuer-bound at the point it is established, The read stays issuer-keyed rather than issuer-only: Covered by the case you asked for, plus its complement, driven against a real
Mutation-checked, each guard independently:
One trade-off worth calling out, since it is a deliberate cost and not an oversight: moving the existing-client check after discovery means a discovery round trip on each connect attempt rather than only the first. There is no cheaper way to learn the issuer, and SDK Note on the previous gate runThe |
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate issues remain around invalid-client recovery and uncached discovery availability.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
core/auth/providers.ts:298
- This contract comment is now inaccurate:
ensureCimdClientRegistrationis an internal caller that supplies bothregistrationKindand the discoveredissuer. Describe the issuer as optional rather than saying internal callers never provide it.
// per-AS keying) and no kind — `resolveSdkRegistrationKind` recovers it —
// while our callers supply the registration kind and no issuer yet.
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Balanced
| ); | ||
| } | ||
| throw new Error(`unexpected fetch: ${url}`); | ||
| }) as unknown as typeof fetch; |
There was a problem hiding this comment.
Fixed in 4297ef8 — removed. The helper is now declared function discoveryFetch(issuer: string, cimd: boolean): typeof fetch and returns the async function directly, so a signature drift is a compile error rather than being cast away.
Agreed on principle too: this repo's AGENTS.md treats an unjustified as unknown as as a review finding in its own right. The five remaining ones in this file are pre-existing (window stubs and the makeStorage shape) and outside this change.
…dential Copilot review (#2287), three findings: 1. **invalid-client recovery lost the provenance.** SDK v2 `auth()` answers `invalid_client` / `unauthorized_client` with `invalidateCredentials("client")` and an immediate retry; our clear drops the registration *and* its kind, so the retry's URL-based client-ID save landed with nothing recorded and was stored as `dcr`. Provenance now lives in `cimdClientMetadataUrl`, an issuer-keyed marker on the issuer slot that records a property of the AS rather than a credential — `clearClientInformation` deliberately leaves it alone. `ensureCimdClientRegistration` writes it, and withdraws it when the AS stops advertising CIMD, on every connect. 2. **Discovery became a hard network dependency.** Moving the existing-client check after discovery meant a well-known outage could fail a reconnect the SDK would have served from its persisted discovery state. Reuse `provider.discoveryState()` first, and treat a discovery failure as "skip pre-registration" rather than an error — this helper is an optimization over what `auth()` does for itself. 3. **Dropped an unjustified double cast** in the test fetch helper; the async signature is directly assignable to `typeof fetch`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review (#2287, suppressed comment): the comment still said internal callers supply no issuer, which stopped being true when ensureCimdClientRegistration started binding its save to the discovered issuer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 5 — 3 comments + 1 suppressed, all addressedCode in 4297ef8, comment fix in 4350731. 1.
|
| Reverted | Result |
|---|---|
resolver reads the clearable clientRegistrationKind again |
2 failed — including the invalid-client case |
make clearClientInformation clear the marker too |
1 failed — the real-storage recovery test |
ensureCimdClientRegistration stops withdrawing the marker |
2 failed |
Worth noting the first version of this test passed against a clearable marker, because it mocked clearClientInformation. That is why the real-storage test exists.
2. core/auth/cimd.ts — discovery became a hard network dependency (inline)
Right, and this was a regression I introduced last round rather than an inherent cost — thanks. Fixed in 4297ef8, both halves:
Reuse the cached discovery. ensureCimdClientRegistration now reads provider.discoveryState() first and uses its authorizationServerMetadata when present, so it does no discovery of its own on the path where auth() would also skip it. Both legs (RFC 9728 and RFC 8414) are behind that check.
A discovery failure is no longer fatal. The AS-metadata leg is wrapped, and on failure the helper returns instead of propagating. This is the right shape regardless of caching: pre-registration is an optimization over what SDK auth() does for itself, so failing to pre-register must never fail a connection — auth() runs its own discovery immediately afterwards and owns the error handling. It also writes no marker in that case, since nothing was learned about the AS.
Two tests:
reuses persisted discovery state instead of re-fetching— seeds discovery state and passes afetchFnthat throws if called; asserts it is never called and the pre-registration still happens.skips pre-registration when discovery fails, rather than throwing— asserts it resolves, saves no client information, and invents no marker.
Mutation-checked: dropping the cached-state read fails the first; removing the catch fails the second.
3. providers.test.ts — unjustified double cast (inline)
Fixed in 4297ef8 — removed. The helper is now declared function discoveryFetch(issuer: string, cimd: boolean): typeof fetch and returns the async function directly, so a signature drift is a compile error rather than being cast away.
Agreed on principle too: this repo's AGENTS.md treats an unjustified as unknown as as a review finding in its own right. The five remaining ones in this file are pre-existing (window stubs and the makeStorage shape) and outside this change.
4. Suppressed comment — stale saveClientInformation contract comment
Correct, and a staleness my round-4 change introduced: ensureCimdClientRegistration began supplying the discovered issuer alongside the kind, so "our callers supply the registration kind and no issuer yet" stopped being true. Reworded in 4350731 to say the issuer is supplied when known, and that the unkeyed slot is only for AS metadata carrying no issuer at all.
There was a problem hiding this comment.
🔵 Needs a closer look
Approval assessments conflict, and the PR description remains inconsistent with the implemented algorithm.
Review details
Suppressed comments (1)
core/auth/providers.ts:397
- The PR description’s stated safety rule is now stale: it says a prior registration recorded as
cimdis required and that “no prior CIMD registration” falls through todcr, but this new-registration branch intentionally returnscimdwith no stored registration by consulting SDK-persisted discovery state. That distinction is central to the fix (including the transient-preflight-failure path), so please update the description’s Fix section to describe the actual two-case algorithm rather than the superseded conjunction.
// A new registration: read back the branch the SDK took.
const discovery = await this.storage.getDiscoveryState(this.serverUrl);
const metadata = discovery?.authorizationServerMetadata;
// Require the metadata to describe *this* issuer, so a state left over from
// a previously resolved AS cannot answer for a different one.
if (issuer !== undefined && metadata?.issuer !== issuer) return "dcr";
return metadata?.client_id_metadata_document_supported === true
? "cimd"
: "dcr";
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Review round 8 — approval, 0 comments · round 9 — description staleness, fixedRound 9's suppressed comment is correct and I've fixed it. The PR description still carried the round-2 conjunction — "a prior registration recorded as The Fix section now describes the real algorithm: an entry condition (CIMD configured, On the conflicting approval assessments: round 8 was |
Gate green on 6273790
This supersedes the red run I reported mid-review: that one was 11 failures across Screenshots recaptured against the final commit, since the mechanism changed substantially after they were first taken. Same result: |
There was a problem hiding this comment.
🔵 Needs a closer look
Complex OAuth storage behavior and a minor E2E coverage gap warrant final human review.
Review details
Suppressed comments (1)
clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts:380
- This E2E assertion no longer exercises
resolveSdkRegistrationKind: the updated preflight saves directly into the issuer-keyed slot,OAuthStorageBasereattaches that issuer on read, and SDK 2.0 only invokessaveClientInformationfor an unstamped or newly created credential. The assertion therefore remainscimdeven if the new resolver always returnsdcr. Add an E2E case where preflight discovery fails once but the SDK's own discovery succeeds (or seed an unstamped legacy registration), so the SDK actually calls the resolver and the reported relabeling regression is covered across the integration boundary.
// #2242: the metadata-document URL is the client_id, and the stored
// provenance still says CIMD after the SDK bound the registration to
// the issuer — no `POST /register` ever happened.
const oauthState = await client.getOAuthState();
expect(oauthState?.client).toMatchObject({
clientId: metadataUrl,
registrationKind: "cimd",
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Copilot review (#2287): the CIMD E2E assertion had gone vacuous. Once the pre-registration started writing the issuer-keyed slot itself, the SDK found an already-stamped credential and never called `saveClientInformation` at all — so the assertion held even with the resolver gutted. Verified: reverting the dispatch to `: "dcr"` left all 34 tests passing. Add a case that seeds the legacy *unkeyed* CIMD registration — what every pre-SEP-2352 install has on disk, and the exact shape #2242 was reported against. The SDK back-stamps it with the issuer, which is the save that used to relabel it `Dynamic (DCR)`, so the resolver is genuinely on the path. That case now fails on both SSE and Streamable HTTP when the dispatch is reverted, which is the reported bug reproduced across the integration boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 11 — suppressed comment, and it was right. Fixed in be08f6dThe CIMD E2E assertion had gone vacuous, and I confirmed it empirically before fixing it: reverting the dispatch to I took the second of your two suggestions — seeding an unstamped legacy registration — because it is not just a way onto the code path, it is the exact shape #2242 was reported against: an unkeyed CIMD registration, which is what every pre-SEP-2352 install has on disk and what the pre-registration itself wrote before it knew the issuer. The SDK back-stamps it with the issuer, and that back-stamp is the save that used to relabel it New case:
That is the reported regression reproduced across the integration boundary. I also left a comment on the original assertion saying what it does and does not cover, so it is not mistaken for resolver coverage again. A full |
There was a problem hiding this comment.
🟡 Changes recommended
The EMA wrapper must delegate issuer context and discovery-state methods, with coverage for that path.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot review (#2287): `EmaTransportOAuthProvider` forwarded `clientInformation` / `saveClientInformation` without the SDK's `ctx`, and implemented neither `discoveryState` nor `saveDiscoveryState`. Since the wrapper does expose `clientMetadataUrl`, an EMA connection can still take SDK `auth()`'s URL-based-client-ID branch — and the inner provider then saw `issuer === undefined` with no discovery state to read back, so the CIMD write was recorded as DCR. Forward `ctx` on both, and delegate the two discovery-state methods to the inner provider. Both were pre-existing SEP-2352 gaps in their own right: dropping the issuer put every EMA read and write on the unkeyed slot, and the missing discovery state meant the SDK re-discovered on every call and warned that it could not run its callback-leg authorization-server binding check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 12 — 1 comment, addressed in aca522d
Confirmed and fixed in aca522d. I read the wrapper: Both are now delegated. Worth noting neither is only a #2242 concern — they were pre-existing SEP-2352 gaps that this fix happened to surface:
Coverage, and the existing delegation test updated for the new arity:
Mutation-checked:
Auth suites |
Review complete — two consecutive clean rounds, gate green on aca522dRounds 13 and 14 both returned What the review changedSix rounds raised code findings, and every one of them was real. The mechanism changed twice as a result:
Round 7 is the one worth flagging: taken with rounds 5 and 6 it said the marker was the wrong mechanism rather than a mechanism needing another patch. Round 11 deserves the same note in the other direction: an assertion I had cited as evidence in earlier rounds had silently stopped covering the resolver, and I only found that out by re-running the mutation. Worth remembering that a mutation result has a shelf life. VerificationEvery guard is mutation-checked independently — twelve reverts, each failing only the tests that own it. The end-to-end reproduction is Screenshots in the description were recaptured against the final code: |
Closes #2242
Connection Info labeled a CIMD registration Dynamic (DCR) even though no
POST /oauth/registerever happened. The Client ID shown was correct (the metadata-document URL); only the provenance was wrong.Screenshots
Same server, same flow, same Client ID — a live CIMD login against a test authorization server advertising
client_id_metadata_document_supported, with a real HTTPS client-metadata document. Neither run made a single request to a registration endpoint. The after shot was recaptured against the final commit.v2/main)Client registration→ Dynamic (DCR)Client registration→ Client ID Metadata (CIMD)The connection both runs produced
Cause
BaseOAuthClientProvider.saveClientInformationaccepts two shapes: our own pre-registration callers pass{ registrationKind }, and the SDK passes{ issuer }. It treated every save without an explicit kind as DCR.The SDK reaches that method from three places in
auth(), and only one of them is a dynamic registration:issuer(SEP-2352)dcrclient_id = clientMetadataUrlwhen the AS advertisesclient_id_metadata_document_supporteddcrcimdregisterClient(...)dcrdcrSo
ensureCimdClientRegistrationstoredcimdcorrectly before authorization, and the issuer-binding write silently overwrote it moments later — which is why clearing OAuth state and reconnecting never helped.Fix
SDK v2's
saveClientInformationcontract passes only{ issuer }, so the mechanism cannot be handed to us. But it does not have to be inferred either.auth()callssaveDiscoveryStatebefore it reads or writes client information, and it takes its URL-based-client-ID branch — rather thanregisterClient— exactly when that metadata advertisesclient_id_metadata_document_supportedand aclientMetadataUrlis configured. So the branch the SDK took is read back from the state the SDK itself just wrote, using the same predicate it branched on.resolveSdkRegistrationKindfirst requires that CIMD is in play at all: configured for this connection, and the incomingclient_idequal to that metadata-document URL. Anything else isdcrimmediately. From there it is two cases, told apart by whether a registration already exists for this issuer:client_id— the SDK is only adding theissuerto itThe URL comparison only decides whether CIMD is in play; the two cases decide what actually happened. That matters because RFC 7591 §3.2 leaves a dynamically issued
client_idopaque, so an AS may mint the metadata URL itself.Consequences, each of which was a defect on the way here:
client_idhappens to be the metadata URL staysdcr— it takes the back-stamp case and its recorded kind says so.invalid_clientrecovery stayscimd.auth()answers that error withinvalidateCredentials("client")and an immediate retry; the clear removes the registration and its kind, so the retry takes the new-registration case and is answered from discovery state, which the clear does not touch.dcreven when it mints the metadata URL as itsclient_id.Supporting changes in
ensureCimdClientRegistration: it binds its pre-registration to the issuer it just discovered (SEP-2352 keys registrations per AS, and its "already registered?" check is keyed the same way — read ctx-less it resolved through the active issuer and early-returned for every later one); it reusesprovider.discoveryState()before fetching; and a discovery failure now skips pre-registration rather than propagating, since this helper is an optimization over whatauth()does for itself.No UI or storage-schema change — Connection Info already renders whatever kind is stored.
Tests
The auth suites are at 627 passing, and the CIMD OAuth E2E at 34. New coverage spans both mocked cases and end-to-end ones driven against a real
OAuthStorageBaseand the realensureCimdClientRegistration, since several of these behaviours are about how storage promotes and clears slots across issuers — which a mock would assert away.Every guard was mutation-checked independently:
: "dcr"ensureCimdClientRegistrationback to its ctx-less early-returnissuerfrom the pre-registration saveReverting the dispatch also fails the CIMD OAuth E2E test on both transports — that failure is the reported bug itself, a completed CIMD connection reporting
registrationKind: "dcr".🤖 Generated with Claude Code
https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA