OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX#1694
Conversation
…1625) Re-key the persisted OAuth store to (server, issuer) and add the SEP-837/2350/2352 authorization-hardening UX. The SDK v2 auth() drives the SEP-2352 flow off the provider's ctx.issuer; the Inspector's job is to honor it and round-trip the issuer stamp. SEP-2352 — issuer-bound credentials - ServerOAuthState nests issuer-bound credentials under byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with an activeIssuer pointer answering the transport's ctx-less per-request bearer read. - Fix a latent bug: OAuthStorageBase re-parsed tokens/client-info through OAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFC `issuer` field and silently defeat the SDK's discardIfIssuerMismatch. The store re-attaches the stamp from the byIssuer key. - BaseOAuthClientProvider threads ctx.issuer and implements discoveryState()/saveDiscoveryState() (activates the SDK's callback-leg AuthorizationServerMismatchError binding check) and invalidateCredentials(). - Lazy, migration-free upgrade: a legacy pre-1625 blob's top-level clientInformation/tokens are returned as an unkeyed fallback until the first issuer-stamped save promotes them into byIssuer. SEP-837 — application_type - DCR declares application_type: "native" (localhost Inspector). - ClientSettingsForm renders RegistrationRejectedError detail and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status. SEP-2350 — step-up scope union - Per-server onInsufficientScope (reauthorize | throw) setting wired ServerSettingsForm -> StoredMCPServer.oauth -> StreamableHTTPClientTransport. - StepUpAuthModal visualizes the prior-union-challenged scope set, tagging each scope `new` vs `already granted`. mcpAuth simplification - Delete the hand-rolled authorizeWithoutRefresh (mishandled issuer stamping); forward forceReauthorization/skipIssuerMetadataValidation to native SDK auth() so both the initial and step-up paths get SEP-2352 stamping. Vocabulary + docs - OAuthStep gains cimd_fetch / issuer_comparison / scope_step_up (recorded on a step-up redirect); the Network tab labels auth requests by OAuth phase. - v2_auth_hardening.md gains an as-built status section. Deferred to the era-aware card: the SdkError(EraNegotiationFailed) -> UnauthorizedError unwrap and finishAuth(URLSearchParams) form (only fire under non-legacy negotiation). Closes #1625 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live smoke test — standard OAuth + step-up against a real serverVerified end-to-end in the running web client (fresh Vite dev build on this branch) against a real standard-OAuth server with DCR, driving the actual Server under test: 1. SEP-2352 — issuer re-keying (initial connect: discovery → DCR → token)Persisted
2. Migration integrity (real pre-1625 store)Diffed the live store against a pre-test backup: Lazy migration touched nothing that existed — only the newly-authorized server gained a 3. SEP-2350 — step-up scope unionSet the new per-server Scopes field to
The 403 → union → re-authorize loop resolved and the widened grant persisted. ✅ Also exercised
GateFull |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 47s —— View job PR Review — OAuth re-key to (server, issuer) + SEP-837/2350/2352 auth UX
Reviewed all 35 files. This is a well-structured change: the store re-keying, the lazy migration, and the 🟡 Legacy-fallback credentials get stamped with an issuer they weren't minted by
const slot = this.issuerSlot(state, issuer);
const clientInfo = slot?.clientInformation ?? state.clientInformation; // may come from legacy fallback
const parsed = await OAuthClientInformationSchema.parseAsync(clientInfo);
return withIssuer(
parsed,
slot ? this.resolveReadIssuer(state, issuer) : undefined, // stamped whenever a slot *exists*
);The Because return withIssuer(
parsed,
slot?.clientInformation ? this.resolveReadIssuer(state, issuer) : undefined,
);
// and for getTokens: slot?.tokens ? ... : undefined🟢 Minor — clearing one issuer's credentials promotes it to
|
…er + phase over-match - SEP-2352: stamp the issuer only when the credential actually came from the byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated slot alongside a surviving legacy field would otherwise stamp a legacy (possibly cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch. - Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer` (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion). - oauthNetworkPhase matches register/token/authorize as the final path segment (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer over-match. - Tests for all three. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — all three addressed in 111d50b. 🟡 Legacy-fallback stamped with an unrelated issuer — fixed. The stamp is now gated on the credential's actual source, not on the slot merely existing. 🟢 Clear promotes to 🟢 Coverage gate green (per-file ≥90, exit 0; 4015 tests). Re-requesting review below. |
|
@claude review |
|
Claude finished @cliffhall's task in 1m 35s —— View job Re-review — OAuth re-key + SEP-837/2350/2352 (after commit
|
Closes #1625
Re-keys the persisted OAuth store to
(server, issuer)and adds the SEP-837/2350/2352 authorization-hardening UX. SDK v2auth()drives the SEP-2352 flow off the provider'sctx.issuer; the Inspector's job is to honor it and round-trip the issuer stamp.SEP-2352 — issuer-bound credentials
ServerOAuthStatenests issuer-bound credentials underbyIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with anactiveIssuerpointer answering the transport's ctx-less per-request bearer read.OAuthStorageBasere-parsed tokens/client-info throughOAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFCissuerfield and silently defeat the SDK'sdiscardIfIssuerMismatch. The store re-attaches the stamp from thebyIssuerkey.BaseOAuthClientProviderthreadsctx.issuerand implementsdiscoveryState()/saveDiscoveryState()(activates the SDK's callback-legAuthorizationServerMismatchErrorbinding check) andinvalidateCredentials().clientInformation/tokensare returned as an unkeyed fallback until the first issuer-stamped save promotes them intobyIssuer. Existing web/TUI/CLI sessions survive.SEP-837 —
application_typeapplication_type: "native"(the Inspector is reached over localhost).ClientSettingsFormrendersRegistrationRejectedErrordetail and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status.SEP-2350 — step-up scope union
onInsufficientScope(reauthorize | throw) setting wiredServerSettingsForm→StoredMCPServer.oauth→StreamableHTTPClientTransport.StepUpAuthModalvisualizes the prior ∪ challenged scope union, tagging each scopenewvsalready granted.mcpAuth simplification
authorizeWithoutRefresh(which mishandled issuer stamping); forwardsforceReauthorization/skipIssuerMetadataValidationto the native SDKauth()so both the initial and step-up paths get SEP-2352 stamping.Vocabulary + docs
OAuthStepgainscimd_fetch/issuer_comparison/scope_step_up(recorded on a step-up redirect); the Network tab labelsauth-category requests by OAuth phase (Discovery/Registration/Authorize/Token).specification/v2_auth_hardening.mdgains an as-built status section.Deferred (per issue triage)
The
SdkError(EraNegotiationFailed) → UnauthorizedErrorunwrap andfinishAuth(URLSearchParams)whole-params form only fire underauto/pinnegotiation; the Inspector is pinned toversionNegotiation: 'legacy', so these land with the era-aware connection model (card 3 of the stack).Screen capture
step-up.mov
Verification
Full
npm run ciequivalent, all green:validate(4 clients: lint/build/test)coverage(per-file ≥90, exit 0 — every changed file clears the gate; new integration coverage for the issuer round-trip, AS-migration fallback, transport option, backend validation)🤖 Generated with Claude Code