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
1 changed files with 1620 additions and 601 deletions
|
|
@ -1,45 +1,22 @@
|
|||
/**
|
||||
* v2-shaped OAuth orchestrator for standard MCP resource authorization.
|
||||
* Thin wrapper over the `@modelcontextprotocol/client` v2 OAuth orchestrator.
|
||||
*
|
||||
* **Upgrade path:** On `@modelcontextprotocol/client` v2, replace this module
|
||||
* with `export { auth as mcpAuth, type AuthOptions as McpAuthOptions } from
|
||||
* "@modelcontextprotocol/client"` and delete `authorizeWithoutRefresh`.
|
||||
*
|
||||
* Until then, `mcpAuth` delegates to SDK v1 `auth()` except when
|
||||
* `forceReauthorization: true` — then it runs discovery + `startAuthorization`
|
||||
* (the same path v2 `auth()` takes when refresh cannot widen scope).
|
||||
* SDK v2's `auth()` handles the full flow natively — including `forceReauthorization`
|
||||
* (skip refresh and start a fresh authorization request when a step-up scope union
|
||||
* cannot be widened by refresh, RFC 6749 §6), RFC 9207 `iss` validation, SEP-2352
|
||||
* issuer stamping/`ctx.issuer` keying, and SEP-837 `application_type` defaults. This
|
||||
* wrapper exists only to pin the option shape (`McpAuthOptions`) the Inspector passes
|
||||
* and to keep a single import site; it no longer reimplements any flow logic.
|
||||
*/
|
||||
|
||||
import {
|
||||
auth as sdkAuth,
|
||||
discoverOAuthServerInfo,
|
||||
isHttpsUrl,
|
||||
registerClient,
|
||||
selectResourceURL,
|
||||
startAuthorization,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { auth as sdkAuth } from "@modelcontextprotocol/client";
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/client";
|
||||
import type { FetchLike } from "@modelcontextprotocol/client";
|
||||
/**
|
||||
* Thrown when the OAuth client metadata the Inspector would register is
|
||||
* invalid (e.g. a non-HTTPS `clientMetadataUrl`). SDK v2 moved the original
|
||||
* `InvalidClientMetadataError` into `@modelcontextprotocol/server-legacy`,
|
||||
* whose `/auth` entry pulls in Node/Express server handlers that must never
|
||||
* reach the browser bundle. This local class preserves the thrown type and
|
||||
* message for the Inspector's client-side validation without that dependency.
|
||||
*/
|
||||
export class InvalidClientMetadataError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidClientMetadataError";
|
||||
}
|
||||
}
|
||||
|
||||
export type McpAuthResult = "AUTHORIZED" | "REDIRECT";
|
||||
|
||||
/**
|
||||
* Options aligned with v2 SDK `AuthOptions`. v2-only fields are accepted now
|
||||
* and forwarded on upgrade; v1 passthrough ignores unsupported fields.
|
||||
* Options forwarded to SDK v2 `auth()`.
|
||||
*/
|
||||
export interface McpAuthOptions {
|
||||
serverUrl: string | URL;
|
||||
|
|
@ -49,11 +26,11 @@ export interface McpAuthOptions {
|
|||
scope?: string;
|
||||
resourceMetadataUrl?: URL;
|
||||
fetchFn?: FetchLike;
|
||||
/** v2 SEP-2468 — ignored until SDK v2 upgrade. */
|
||||
/** SEP-2468 opt-out for the RFC 8414 §3.3 issuer-echo check (security-weakening). */
|
||||
skipIssuerMetadataValidation?: boolean;
|
||||
/**
|
||||
* Skip refresh and start an authorization-code flow. Required for step-up
|
||||
* when the union scope exceeds the current token grant (RFC 6749 §6).
|
||||
* Skip refresh and start an authorization-code flow. Required for step-up when
|
||||
* the union scope exceeds the current token grant (RFC 6749 §6).
|
||||
*/
|
||||
forceReauthorization?: boolean;
|
||||
}
|
||||
|
|
@ -62,13 +39,10 @@ export async function mcpAuth(
|
|||
provider: OAuthClientProvider,
|
||||
options: McpAuthOptions,
|
||||
): Promise<McpAuthResult> {
|
||||
if (options.forceReauthorization) {
|
||||
if (options.authorizationCode !== undefined) {
|
||||
throw new Error(
|
||||
"forceReauthorization cannot be combined with authorizationCode",
|
||||
);
|
||||
}
|
||||
return authorizeWithoutRefresh(provider, options);
|
||||
if (options.forceReauthorization && options.authorizationCode !== undefined) {
|
||||
throw new Error(
|
||||
"forceReauthorization cannot be combined with authorizationCode",
|
||||
);
|
||||
}
|
||||
|
||||
return sdkAuth(provider, {
|
||||
|
|
@ -78,93 +52,7 @@ export async function mcpAuth(
|
|||
scope: options.scope,
|
||||
resourceMetadataUrl: options.resourceMetadataUrl,
|
||||
fetchFn: options.fetchFn,
|
||||
skipIssuerMetadataValidation: options.skipIssuerMetadataValidation,
|
||||
forceReauthorization: options.forceReauthorization,
|
||||
});
|
||||
}
|
||||
|
||||
async function authorizeWithoutRefresh(
|
||||
provider: OAuthClientProvider,
|
||||
options: McpAuthOptions,
|
||||
): Promise<"REDIRECT"> {
|
||||
const { serverUrl, scope, resourceMetadataUrl, fetchFn } = options;
|
||||
|
||||
const serverInfo = await discoverOAuthServerInfo(serverUrl, {
|
||||
resourceMetadataUrl,
|
||||
fetchFn,
|
||||
});
|
||||
const {
|
||||
authorizationServerUrl,
|
||||
authorizationServerMetadata: metadata,
|
||||
resourceMetadata,
|
||||
} = serverInfo;
|
||||
|
||||
await provider.saveDiscoveryState?.({
|
||||
authorizationServerUrl: String(authorizationServerUrl),
|
||||
resourceMetadataUrl: resourceMetadataUrl?.toString(),
|
||||
resourceMetadata,
|
||||
authorizationServerMetadata: metadata,
|
||||
});
|
||||
|
||||
const resource = await selectResourceURL(
|
||||
serverUrl,
|
||||
provider,
|
||||
resourceMetadata,
|
||||
);
|
||||
|
||||
const resolvedScope =
|
||||
scope ||
|
||||
resourceMetadata?.scopes_supported?.join(" ") ||
|
||||
provider.clientMetadata.scope;
|
||||
|
||||
let clientInformation = await Promise.resolve(provider.clientInformation());
|
||||
if (!clientInformation) {
|
||||
const supportsUrlBasedClientId =
|
||||
metadata?.client_id_metadata_document_supported === true;
|
||||
const clientMetadataUrl = provider.clientMetadataUrl;
|
||||
if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) {
|
||||
throw new InvalidClientMetadataError(
|
||||
`clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`,
|
||||
);
|
||||
}
|
||||
const shouldUseUrlBasedClientId =
|
||||
supportsUrlBasedClientId && clientMetadataUrl;
|
||||
if (shouldUseUrlBasedClientId) {
|
||||
clientInformation = { client_id: clientMetadataUrl };
|
||||
await provider.saveClientInformation?.(clientInformation);
|
||||
} else {
|
||||
if (!provider.saveClientInformation) {
|
||||
throw new Error(
|
||||
"OAuth client information must be saveable for dynamic registration",
|
||||
);
|
||||
}
|
||||
const fullInformation = await registerClient(authorizationServerUrl, {
|
||||
metadata,
|
||||
clientMetadata: provider.clientMetadata,
|
||||
scope: resolvedScope,
|
||||
fetchFn,
|
||||
});
|
||||
await provider.saveClientInformation(fullInformation);
|
||||
clientInformation = fullInformation;
|
||||
}
|
||||
}
|
||||
|
||||
const state = provider.state ? await provider.state() : undefined;
|
||||
const redirectUrl = provider.redirectUrl;
|
||||
if (!redirectUrl) {
|
||||
throw new Error("redirectUrl is required for authorization_code flow");
|
||||
}
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||
authorizationServerUrl,
|
||||
{
|
||||
metadata,
|
||||
clientInformation,
|
||||
state,
|
||||
redirectUrl,
|
||||
scope: resolvedScope,
|
||||
resource,
|
||||
},
|
||||
);
|
||||
|
||||
await provider.saveCodeVerifier(codeVerifier);
|
||||
await provider.redirectToAuthorization(authorizationUrl);
|
||||
return "REDIRECT";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue