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
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:
parent
f915889fc4
commit
7fecebed5e
5 changed files with 1620 additions and 601 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue