OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1694)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

* OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#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>

* Address @claude review: issuer-stamp source gating + clear/activeIssuer + 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>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-16 16:52:53 -04:00 • committed by GitHub
commit 7fecebed5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1620 additions and 601 deletions

View file

@ -211,6 +211,11 @@ export function createTransportNode(
authProvider,
requestInit,
fetch: transportFetch,
// SEP-2350: how the transport reacts to a `403 insufficient_scope`
// challenge. Defaults to the SDK's `reauthorize` when unset.
...(settings?.oauthOnInsufficientScope && {
onInsufficientScope: settings.oauthOnInsufficientScope,
}),
});
return { transport };

View file

@ -771,7 +771,14 @@ export class OAuthManager {
}
const clientInfo = await provider.clientInformation();
await this.recordAuthorizationCodeFlowState(capturedUrl, clientInfo);
await this.recordAuthorizationCodeFlowState(
capturedUrl,
clientInfo,
// SEP-2350: label a step-up redirect distinctly from a first-time login.
enriched.reason === "insufficient_scope"
? "scope_step_up"
: "authorization_code",
);
return {
kind: "interactive",
@ -810,10 +817,11 @@ export class OAuthManager {
private async recordAuthorizationCodeFlowState(
authorizationUrl: URL,
oauthClientInfo?: OAuthClientInformation | null,
step: OAuthStep = "authorization_code",
): Promise<void> {
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_code",
oauthStep: step,
authorizationUrl,
oauthClientInfo: oauthClientInfo ?? null,
};

View file

@ -1415,6 +1415,17 @@ export function createRemoteApp(
error: "settings.enterpriseManaged must be a boolean",
};
}
if (
obj.oauthOnInsufficientScope !== undefined &&
obj.oauthOnInsufficientScope !== "reauthorize" &&
obj.oauthOnInsufficientScope !== "throw"
) {
return {
ok: false,
error:
"settings.oauthOnInsufficientScope must be 'reauthorize' or 'throw'",
};
}
// roots is optional on the wire (older clients won't send it); when
// present it must be an array of `{ uri, name? }`. Reuses the same
// module-scope `isRootArray` guard the read path applies in
@ -1477,6 +1488,12 @@ export function createRemoteApp(
if (obj.enterpriseManaged === true) {
value.enterpriseManaged = true;
}
if (
obj.oauthOnInsufficientScope === "reauthorize" ||
obj.oauthOnInsufficientScope === "throw"
) {
value.oauthOnInsufficientScope = obj.oauthOnInsufficientScope;
}
// Empty cwd coerces to absent on the value (matching the read side); the
// write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted"
// (preserve) via `cwdProvided` below rather than the value alone.

View file

@ -197,6 +197,9 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.clientSecret)
settings.oauthClientSecret = stored.oauth.clientSecret;
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}
if (stored.oauth?.enterpriseManaged === true) {
settings.enterpriseManaged = true;
}
@ -263,16 +266,14 @@ export function inspectorSettingsToStoredFields(
out.maxFetchRequests = settings.maxFetchRequests;
}
const oauthFields: {
clientId?: string;
clientSecret?: string;
scopes?: string;
enterpriseManaged?: boolean;
} = {};
const oauthFields: NonNullable<StoredMCPServer["oauth"]> = {};
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
if (settings.oauthClientSecret)
oauthFields.clientSecret = settings.oauthClientSecret;
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
if (settings.oauthOnInsufficientScope) {
oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope;
}
if (settings.enterpriseManaged === true) {
oauthFields.enterpriseManaged = true;
}

View file

@ -134,6 +134,8 @@ export type StoredMCPServer = MCPServerConfig & {
scopes?: string;
/** When true, connect via enterprise IdP (EMA) instead of standard resource OAuth. */
enterpriseManaged?: boolean;
/** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */
onInsufficientScope?: OnInsufficientScopePolicy;
};
/**
* Filesystem/URI roots advertised to the server via the `roots` client
@ -406,8 +408,17 @@ export interface OAuthSettings {
clientSecret: string;
scopes: string;
enterpriseManaged?: boolean;
onInsufficientScope?: OnInsufficientScopePolicy;
}
/**
* SEP-2350 step-up policy for a `403 insufficient_scope` challenge. `reauthorize`
* (the SDK default) drives step-up authorization with the accumulated scope union;
* `throw` surfaces the challenge to the host instead. Forwarded to the
* StreamableHTTP transport's `onInsufficientScope` option.
*/
export type OnInsufficientScopePolicy = "reauthorize" | "throw";
/**
* Default TTL (ms) for tasks created via "Run as task". Mirrors v1/v1.5's
* `MCP_TASK_TTL` config default. Used when a server has no explicit `taskTtl`.
@ -454,6 +465,11 @@ export interface InspectorServerSettings {
oauthClientId?: string;
oauthClientSecret?: string;
oauthScopes?: string;
/**
* SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this
* server's HTTP transport. Defaults to `reauthorize` when unset.
*/
oauthOnInsufficientScope?: OnInsufficientScopePolicy;
/**
* When true, connect via the configured enterprise IdP (EMA) instead of
* interactive OAuth to the MCP authorization server. Per-server OAuth