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>
244 lines
6.4 KiB
TypeScript
244 lines
6.4 KiB
TypeScript
import type {
|
||
OAuthClientInformation,
|
||
OAuthTokens,
|
||
OAuthMetadata,
|
||
OAuthDiscoveryState,
|
||
} from "@modelcontextprotocol/client";
|
||
import type { OAuthClientRegistrationKind } from "./types.js";
|
||
|
||
/**
|
||
* Abstract storage interface for OAuth state
|
||
* Supports browser (sessionStorage), Node.js (file), and remote HTTP backends.
|
||
*/
|
||
export interface SaveTokensOptions {
|
||
/** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */
|
||
enterpriseManaged?: boolean;
|
||
/**
|
||
* Authorization-server `issuer` these tokens are bound to (SEP-2352). When set,
|
||
* tokens are keyed under `(server, issuer)`; when omitted (EMA / legacy callers)
|
||
* they write the per-server fallback slot.
|
||
*/
|
||
issuer?: string;
|
||
}
|
||
|
||
export type { OAuthClientRegistrationKind };
|
||
|
||
export interface SaveClientInformationOptions {
|
||
registrationKind: "dcr" | "cimd";
|
||
/**
|
||
* Authorization-server `issuer` this client registration is bound to (SEP-2352).
|
||
* Client identifiers are unique to the AS that issued them (RFC 6749 §2.2).
|
||
*/
|
||
issuer?: string;
|
||
}
|
||
|
||
export interface OAuthStorage {
|
||
/**
|
||
* Optional preload of persisted state into memory. Getters and setters load
|
||
* automatically when needed; use this only for fail-fast at known boundaries
|
||
* (e.g. OAuth callback resume after a full-page navigation).
|
||
*/
|
||
load(): Promise<void>;
|
||
|
||
/**
|
||
* Get client information (preregistered or dynamically registered).
|
||
*
|
||
* @param issuer - When set, return the registration bound to this AS `issuer`
|
||
* (SEP-2352). When omitted, return the active-issuer slot, falling back to
|
||
* the legacy unkeyed entry.
|
||
*/
|
||
getClientInformation(
|
||
serverUrl: string,
|
||
isPreregistered?: boolean,
|
||
issuer?: string,
|
||
): Promise<OAuthClientInformation | undefined>;
|
||
|
||
/**
|
||
* Get how the dynamic client registration slot was established.
|
||
*/
|
||
getClientRegistrationKind(
|
||
serverUrl: string,
|
||
issuer?: string,
|
||
): Promise<OAuthClientRegistrationKind | undefined>;
|
||
|
||
/**
|
||
* Save client information (dynamically registered)
|
||
*/
|
||
saveClientInformation(
|
||
serverUrl: string,
|
||
clientInformation: OAuthClientInformation,
|
||
options: SaveClientInformationOptions,
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Save preregistered client information (static client from config)
|
||
*/
|
||
savePreregisteredClientInformation(
|
||
serverUrl: string,
|
||
clientInformation: OAuthClientInformation,
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Clear client information. When `issuer` is set, clear only that AS's
|
||
* registration; when omitted, clear every issuer's registration plus the
|
||
* legacy unkeyed entry.
|
||
*/
|
||
clearClientInformation(
|
||
serverUrl: string,
|
||
isPreregistered?: boolean,
|
||
issuer?: string,
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Get OAuth tokens. When `issuer` is set, return that AS's tokens (SEP-2352);
|
||
* when omitted, return the active-issuer tokens, falling back to the legacy
|
||
* unkeyed entry (the transport's per-request bearer read).
|
||
*/
|
||
getTokens(
|
||
serverUrl: string,
|
||
issuer?: string,
|
||
): Promise<OAuthTokens | undefined>;
|
||
|
||
/**
|
||
* Save OAuth tokens
|
||
*/
|
||
saveTokens(
|
||
serverUrl: string,
|
||
tokens: OAuthTokens,
|
||
options?: SaveTokensOptions,
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Clear OAuth tokens. When `issuer` is set, clear only that AS's tokens; when
|
||
* omitted, clear every issuer's tokens plus the legacy unkeyed entry.
|
||
*/
|
||
clearTokens(serverUrl: string, issuer?: string): Promise<void>;
|
||
|
||
/**
|
||
* Get code verifier (for PKCE)
|
||
*/
|
||
getCodeVerifier(serverUrl: string): Promise<string | undefined>;
|
||
|
||
/**
|
||
* Save code verifier (for PKCE)
|
||
*/
|
||
saveCodeVerifier(serverUrl: string, codeVerifier: string): Promise<void>;
|
||
|
||
/**
|
||
* Clear code verifier
|
||
*/
|
||
clearCodeVerifier(serverUrl: string): Promise<void>;
|
||
|
||
/**
|
||
* Get scope
|
||
*/
|
||
getScope(serverUrl: string): Promise<string | undefined>;
|
||
|
||
/**
|
||
* Save scope
|
||
*/
|
||
saveScope(serverUrl: string, scope: string | undefined): Promise<void>;
|
||
|
||
/**
|
||
* Clear scope
|
||
*/
|
||
clearScope(serverUrl: string): Promise<void>;
|
||
|
||
/**
|
||
* Get server metadata discovered during OAuth
|
||
*/
|
||
getServerMetadata(serverUrl: string): Promise<OAuthMetadata | null>;
|
||
|
||
/**
|
||
* Save server metadata discovered during OAuth
|
||
*/
|
||
saveServerMetadata(serverUrl: string, metadata: OAuthMetadata): Promise<void>;
|
||
|
||
/**
|
||
* Clear server metadata
|
||
*/
|
||
clearServerMetadata(serverUrl: string): Promise<void>;
|
||
|
||
/**
|
||
* Get the cached RFC 9728/8414 discovery state (SEP-2352). The SDK restores it
|
||
* to skip re-discovery and, on the authorization-code callback leg, to bind the
|
||
* exchange to the AS that minted the code (`AuthorizationServerMismatchError`).
|
||
*/
|
||
getDiscoveryState(
|
||
serverUrl: string,
|
||
): Promise<OAuthDiscoveryState | undefined>;
|
||
|
||
/**
|
||
* Save the RFC 9728/8414 discovery state. Persisted alongside the code verifier
|
||
* so it survives the authorization redirect round-trip.
|
||
*/
|
||
saveDiscoveryState(
|
||
serverUrl: string,
|
||
state: OAuthDiscoveryState,
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Clear the cached discovery state (SDK `invalidateCredentials('discovery')`).
|
||
*/
|
||
clearDiscoveryState(serverUrl: string): Promise<void>;
|
||
|
||
/**
|
||
* Clear all OAuth data for a server
|
||
*/
|
||
clear(serverUrl: string): Promise<void>;
|
||
|
||
/**
|
||
* Get cached IdP OIDC session for EMA (keyed by issuer).
|
||
*/
|
||
getIdpSession(issuer: string): Promise<IdpSessionState | undefined>;
|
||
|
||
/**
|
||
* Save IdP OIDC session fields for EMA.
|
||
*/
|
||
saveIdpSession(
|
||
issuer: string,
|
||
session: Partial<IdpSessionState>,
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Clear cached IdP session for an issuer.
|
||
*/
|
||
clearIdpSession(issuer: string): Promise<void>;
|
||
|
||
/**
|
||
* Remove per-server OAuth state for MCP servers whose tokens were minted via EMA.
|
||
*/
|
||
clearEnterpriseManagedResourceServers(): Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* Cached IdP OIDC session for EMA leg 1.
|
||
*/
|
||
export interface IdpSessionState {
|
||
idToken?: string;
|
||
refreshToken?: string;
|
||
/** Epoch ms when the ID Token expires (when known). */
|
||
idTokenExpiresAt?: number;
|
||
}
|
||
|
||
/**
|
||
* Generate server-specific storage key
|
||
*/
|
||
export function getServerSpecificKey(
|
||
baseKey: string,
|
||
serverUrl: string,
|
||
): string {
|
||
return `[${serverUrl}] ${baseKey}`;
|
||
}
|
||
|
||
/**
|
||
* Base storage keys for OAuth data
|
||
*/
|
||
export const OAUTH_STORAGE_KEYS = {
|
||
CODE_VERIFIER: "mcp_code_verifier",
|
||
TOKENS: "mcp_tokens",
|
||
CLIENT_INFORMATION: "mcp_client_information",
|
||
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
|
||
SERVER_METADATA: "mcp_server_metadata",
|
||
SCOPE: "mcp_scope",
|
||
} as const;
|