inspector/core/auth/storage.ts
Cliff Hall 7fecebed5e
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 (#1694)
* 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>
2026-07-16 16:52:53 -04:00

244 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;