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
6 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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,18 @@ import type {
|
|||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
OAuthDiscoveryState,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
OAuthClientInformationSchema,
|
||||
OAuthTokensSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import type { OAuthStorage } from "./storage.js";
|
||||
import { type OAuthMemoryStore, type ServerOAuthState } from "./store.js";
|
||||
import {
|
||||
type OAuthMemoryStore,
|
||||
type ServerOAuthState,
|
||||
type IssuerBoundOAuthState,
|
||||
} from "./store.js";
|
||||
import type { OAuthPersistBackend } from "./oauth-persist.js";
|
||||
import type {
|
||||
IdpSessionState,
|
||||
|
|
@ -17,6 +22,16 @@ import type {
|
|||
SaveTokensOptions,
|
||||
} from "./storage.js";
|
||||
|
||||
/**
|
||||
* Re-attach the `issuer` stamp (SEP-2352) that `OAuthTokensSchema` /
|
||||
* `OAuthClientInformationSchema` strip on parse. The stamp is the `byIssuer` key,
|
||||
* so it is recovered from the key rather than the parsed value — this is what lets
|
||||
* the SDK's `discardIfIssuerMismatch` reject cross-AS credential reuse.
|
||||
*/
|
||||
function withIssuer<T extends object>(value: T, issuer: string | undefined): T {
|
||||
return issuer === undefined ? value : { ...value, issuer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete OAuthStorage implementation backed by in-memory state and an explicit
|
||||
* persist backend (file, remote HTTP, sessionStorage, …).
|
||||
|
|
@ -66,29 +81,109 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
await tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AS-`issuer` key whose credentials answer a read. An explicit
|
||||
* `issuer` (from the SDK's `ctx.issuer`) wins; otherwise the most-recently-saved
|
||||
* `activeIssuer` answers the transport's ctx-less per-request bearer read.
|
||||
*/
|
||||
private resolveReadIssuer(
|
||||
state: ServerOAuthState,
|
||||
issuer?: string,
|
||||
): string | undefined {
|
||||
return issuer ?? state.activeIssuer;
|
||||
}
|
||||
|
||||
/** Read the issuer-bound slot for a read, if one exists. */
|
||||
private issuerSlot(
|
||||
state: ServerOAuthState,
|
||||
issuer?: string,
|
||||
): IssuerBoundOAuthState | undefined {
|
||||
const key = this.resolveReadIssuer(state, issuer);
|
||||
return key ? state.byIssuer?.[key] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-modify-write the `byIssuer[issuer]` slot, set `activeIssuer`, and clear
|
||||
* the given legacy top-level fallback fields (lazy migration — the fallback is
|
||||
* promoted into the keyed slot on first issuer-stamped save).
|
||||
*/
|
||||
private updateIssuerSlot(
|
||||
serverUrl: string,
|
||||
issuer: string,
|
||||
updates: Partial<IssuerBoundOAuthState>,
|
||||
clearLegacy: Partial<ServerOAuthState>,
|
||||
// Saves promote the issuer to `activeIssuer` (it answers ctx-less reads);
|
||||
// clears must not — clearing one AS's credentials shouldn't make it the
|
||||
// active one.
|
||||
setActive = true,
|
||||
): void {
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
const byIssuer = {
|
||||
...state.byIssuer,
|
||||
[issuer]: { ...state.byIssuer?.[issuer], ...updates },
|
||||
};
|
||||
this.memory.getState().setServerState(serverUrl, {
|
||||
byIssuer,
|
||||
...(setActive && { activeIssuer: issuer }),
|
||||
...clearLegacy,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply `fn` to every issuer slot (used by issuer-agnostic clears). */
|
||||
private mapIssuerSlots(
|
||||
state: ServerOAuthState,
|
||||
fn: (slot: IssuerBoundOAuthState) => Partial<IssuerBoundOAuthState>,
|
||||
): Record<string, IssuerBoundOAuthState> {
|
||||
const byIssuer: Record<string, IssuerBoundOAuthState> = {};
|
||||
for (const [key, slot] of Object.entries(state.byIssuer ?? {})) {
|
||||
byIssuer[key] = { ...slot, ...fn(slot) };
|
||||
}
|
||||
return byIssuer;
|
||||
}
|
||||
|
||||
async getClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
issuer?: string,
|
||||
): Promise<OAuthClientInformation | undefined> {
|
||||
await this.ensureLoaded();
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
const clientInfo = isPreregistered
|
||||
? state.preregisteredClientInformation
|
||||
: state.clientInformation;
|
||||
|
||||
if (isPreregistered) {
|
||||
const prereg = state.preregisteredClientInformation;
|
||||
return prereg
|
||||
? await OAuthClientInformationSchema.parseAsync(prereg)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Per-issuer registration (SEP-2352), falling back to the legacy unkeyed slot.
|
||||
const slot = this.issuerSlot(state, issuer);
|
||||
const fromSlot = slot?.clientInformation;
|
||||
const clientInfo = fromSlot ?? state.clientInformation;
|
||||
if (!clientInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await OAuthClientInformationSchema.parseAsync(clientInfo);
|
||||
const parsed = await OAuthClientInformationSchema.parseAsync(clientInfo);
|
||||
// Stamp only when the value actually came from the issuer-keyed slot. Gating
|
||||
// on `fromSlot` (not merely on the slot existing) keeps a legacy unkeyed
|
||||
// credential — potentially minted by a *different* AS — from being stamped
|
||||
// with this issuer, which would defeat the SDK's discardIfIssuerMismatch.
|
||||
return withIssuer(
|
||||
parsed,
|
||||
fromSlot ? this.resolveReadIssuer(state, issuer) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async getClientRegistrationKind(
|
||||
serverUrl: string,
|
||||
issuer?: string,
|
||||
): Promise<OAuthClientRegistrationKind | undefined> {
|
||||
await this.ensureLoaded();
|
||||
return this.memory.getState().getServerState(serverUrl)
|
||||
.clientRegistrationKind;
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
return (
|
||||
this.issuerSlot(state, issuer)?.clientRegistrationKind ??
|
||||
state.clientRegistrationKind
|
||||
);
|
||||
}
|
||||
|
||||
async saveClientInformation(
|
||||
|
|
@ -97,10 +192,24 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
options: SaveClientInformationOptions,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.memory.getState().setServerState(serverUrl, {
|
||||
clientInformation,
|
||||
clientRegistrationKind: options.registrationKind,
|
||||
});
|
||||
if (options.issuer !== undefined) {
|
||||
this.updateIssuerSlot(
|
||||
serverUrl,
|
||||
options.issuer,
|
||||
{
|
||||
clientInformation,
|
||||
clientRegistrationKind: options.registrationKind,
|
||||
},
|
||||
{ clientInformation: undefined, clientRegistrationKind: undefined },
|
||||
);
|
||||
} else {
|
||||
// No issuer yet (our own DCR/CIMD pre-registration callers): write the
|
||||
// unkeyed slot; the SDK promotes it per-issuer on the next stamped save.
|
||||
this.memory.getState().setServerState(serverUrl, {
|
||||
clientInformation,
|
||||
clientRegistrationKind: options.registrationKind,
|
||||
});
|
||||
}
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
|
|
@ -119,29 +228,61 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
async clearClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
issuer?: string,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const updates: Partial<ServerOAuthState> = {};
|
||||
|
||||
if (isPreregistered) {
|
||||
updates.preregisteredClientInformation = undefined;
|
||||
} else {
|
||||
updates.clientInformation = undefined;
|
||||
updates.clientRegistrationKind = undefined;
|
||||
this.memory.getState().setServerState(serverUrl, {
|
||||
preregisteredClientInformation: undefined,
|
||||
});
|
||||
await this.persist();
|
||||
return;
|
||||
}
|
||||
|
||||
this.memory.getState().setServerState(serverUrl, updates);
|
||||
if (issuer !== undefined) {
|
||||
this.updateIssuerSlot(
|
||||
serverUrl,
|
||||
issuer,
|
||||
{ clientInformation: undefined, clientRegistrationKind: undefined },
|
||||
{},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
// Clear every issuer's registration plus the legacy unkeyed entry.
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
const byIssuer = this.mapIssuerSlots(state, () => ({
|
||||
clientInformation: undefined,
|
||||
clientRegistrationKind: undefined,
|
||||
}));
|
||||
this.memory.getState().setServerState(serverUrl, {
|
||||
byIssuer,
|
||||
clientInformation: undefined,
|
||||
clientRegistrationKind: undefined,
|
||||
});
|
||||
}
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async getTokens(serverUrl: string): Promise<OAuthTokens | undefined> {
|
||||
async getTokens(
|
||||
serverUrl: string,
|
||||
issuer?: string,
|
||||
): Promise<OAuthTokens | undefined> {
|
||||
await this.ensureLoaded();
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
if (!state.tokens) {
|
||||
const slot = this.issuerSlot(state, issuer);
|
||||
const fromSlot = slot?.tokens;
|
||||
const tokens = fromSlot ?? state.tokens;
|
||||
if (!tokens) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await OAuthTokensSchema.parseAsync(state.tokens);
|
||||
const parsed = await OAuthTokensSchema.parseAsync(tokens);
|
||||
// Stamp only when the value came from the issuer-keyed slot (see
|
||||
// getClientInformation) — never stamp a legacy unkeyed token with this issuer.
|
||||
return withIssuer(
|
||||
parsed,
|
||||
fromSlot ? this.resolveReadIssuer(state, issuer) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async saveTokens(
|
||||
|
|
@ -150,16 +291,46 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
options?: SaveTokensOptions,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.memory.getState().setServerState(serverUrl, {
|
||||
tokens,
|
||||
...(options?.enterpriseManaged === true && { enterpriseManaged: true }),
|
||||
});
|
||||
if (options?.issuer !== undefined) {
|
||||
this.updateIssuerSlot(
|
||||
serverUrl,
|
||||
options.issuer,
|
||||
{ tokens },
|
||||
{
|
||||
tokens: undefined,
|
||||
...(options.enterpriseManaged === true && {
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.memory.getState().setServerState(serverUrl, {
|
||||
tokens,
|
||||
...(options?.enterpriseManaged === true && { enterpriseManaged: true }),
|
||||
});
|
||||
}
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async clearTokens(serverUrl: string): Promise<void> {
|
||||
async clearTokens(serverUrl: string, issuer?: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.memory.getState().setServerState(serverUrl, { tokens: undefined });
|
||||
if (issuer !== undefined) {
|
||||
this.updateIssuerSlot(
|
||||
serverUrl,
|
||||
issuer,
|
||||
{ tokens: undefined },
|
||||
{},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
const byIssuer = this.mapIssuerSlots(state, () => ({
|
||||
tokens: undefined,
|
||||
}));
|
||||
this.memory
|
||||
.getState()
|
||||
.setServerState(serverUrl, { byIssuer, tokens: undefined });
|
||||
}
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +400,30 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
await this.persist();
|
||||
}
|
||||
|
||||
async getDiscoveryState(
|
||||
serverUrl: string,
|
||||
): Promise<OAuthDiscoveryState | undefined> {
|
||||
await this.ensureLoaded();
|
||||
return this.memory.getState().getServerState(serverUrl).discoveryState;
|
||||
}
|
||||
|
||||
async saveDiscoveryState(
|
||||
serverUrl: string,
|
||||
state: OAuthDiscoveryState,
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.memory.getState().setServerState(serverUrl, { discoveryState: state });
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async clearDiscoveryState(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.memory
|
||||
.getState()
|
||||
.setServerState(serverUrl, { discoveryState: undefined });
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async clear(serverUrl: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.memory.getState().clearServerState(serverUrl);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
OAuthDiscoveryState,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
|
||||
import { generateOAuthState } from "./utils.js";
|
||||
|
|
@ -178,6 +179,13 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
client_name: "MCP Inspector",
|
||||
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
||||
scope: this.scope ?? "",
|
||||
// SEP-837: the Inspector is a locally-hosted app reached over localhost, so
|
||||
// it registers as a native client. OIDC-flavored ASes default an omitted
|
||||
// `application_type` to `"web"`, which forbids loopback redirect URIs and
|
||||
// rejects DCR. (The SDK also infers `"native"` from loopback `redirect_uris`;
|
||||
// declaring it explicitly keeps the value visible and correct even when the
|
||||
// redirect host is not itself a loopback literal.)
|
||||
application_type: "native",
|
||||
};
|
||||
|
||||
// Note: clientMetadataUrl for CIMD mode is passed to registerClient() directly,
|
||||
|
|
@ -190,8 +198,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
return generateOAuthState();
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
// Try preregistered first, then dynamically registered
|
||||
async clientInformation(
|
||||
ctx?: OAuthClientInformationContext,
|
||||
): Promise<OAuthClientInformation | undefined> {
|
||||
// Try preregistered (static, issuer-independent) first, then the per-issuer
|
||||
// dynamic registration (SEP-2352 — keyed by `ctx.issuer`).
|
||||
const preregistered = await this.storage.getClientInformation(
|
||||
this.serverUrl,
|
||||
true,
|
||||
|
|
@ -199,7 +210,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
if (preregistered) {
|
||||
return preregistered;
|
||||
}
|
||||
return await this.storage.getClientInformation(this.serverUrl, false);
|
||||
return await this.storage.getClientInformation(
|
||||
this.serverUrl,
|
||||
false,
|
||||
ctx?.issuer,
|
||||
);
|
||||
}
|
||||
|
||||
async saveClientInformation(
|
||||
|
|
@ -207,19 +222,22 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
// SDK v2's `OAuthClientProvider.saveClientInformation` passes an
|
||||
// `OAuthClientInformationContext` ({ issuer }); our own DCR/CIMD callers
|
||||
// pass `SaveClientInformationOptions` ({ registrationKind }). Accept either
|
||||
// and narrow on the discriminating key. Per-issuer keying (SEP-2352) is a
|
||||
// later card — for now we only read the registration kind.
|
||||
// and read whichever keys are present: the SDK supplies `issuer` (SEP-2352
|
||||
// per-AS keying) and defaults registration kind to DCR; our callers supply
|
||||
// the registration kind and no issuer yet.
|
||||
options?: SaveClientInformationOptions | OAuthClientInformationContext,
|
||||
): Promise<void> {
|
||||
const registrationKind =
|
||||
options && "registrationKind" in options
|
||||
? options.registrationKind
|
||||
: "dcr";
|
||||
const issuer = options && "issuer" in options ? options.issuer : undefined;
|
||||
await this.storage.saveClientInformation(
|
||||
this.serverUrl,
|
||||
clientInformation,
|
||||
{
|
||||
registrationKind,
|
||||
issuer,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -238,12 +256,19 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
);
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return await this.storage.getTokens(this.serverUrl);
|
||||
async tokens(
|
||||
ctx?: OAuthClientInformationContext,
|
||||
): Promise<OAuthTokens | undefined> {
|
||||
return await this.storage.getTokens(this.serverUrl, ctx?.issuer);
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await this.storage.saveTokens(this.serverUrl, tokens);
|
||||
async saveTokens(
|
||||
tokens: OAuthTokens,
|
||||
ctx?: OAuthClientInformationContext,
|
||||
): Promise<void> {
|
||||
await this.storage.saveTokens(this.serverUrl, tokens, {
|
||||
issuer: ctx?.issuer,
|
||||
});
|
||||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL): void {
|
||||
|
|
@ -285,4 +310,47 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
async saveServerMetadata(metadata: OAuthMetadata): Promise<void> {
|
||||
await this.storage.saveServerMetadata(this.serverUrl, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEP-2352 discovery-state round-trip. The SDK persists RFC 9728/8414 discovery
|
||||
* here (alongside the code verifier) so that on the authorization-code callback
|
||||
* leg it can compare the resolved AS `issuer` against the one recorded at
|
||||
* redirect time and reject a mismatch (`AuthorizationServerMismatchError`).
|
||||
* Without these two methods the SDK only `console.warn`s and the binding check
|
||||
* is inactive.
|
||||
*/
|
||||
async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
|
||||
await this.storage.saveDiscoveryState(this.serverUrl, state);
|
||||
}
|
||||
|
||||
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
||||
return this.storage.getDiscoveryState(this.serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEP-2352 credential invalidation. The SDK calls this to drop credentials the
|
||||
* server has rejected; hosts also call `'discovery'` on repeated 401s so a
|
||||
* changed `authorization_servers` list is re-fetched.
|
||||
*/
|
||||
async invalidateCredentials(
|
||||
scope: "all" | "client" | "tokens" | "verifier" | "discovery",
|
||||
): Promise<void> {
|
||||
switch (scope) {
|
||||
case "all":
|
||||
await this.storage.clear(this.serverUrl);
|
||||
return;
|
||||
case "client":
|
||||
await this.storage.clearClientInformation(this.serverUrl);
|
||||
return;
|
||||
case "tokens":
|
||||
await this.storage.clearTokens(this.serverUrl);
|
||||
return;
|
||||
case "verifier":
|
||||
await this.storage.clearCodeVerifier(this.serverUrl);
|
||||
return;
|
||||
case "discovery":
|
||||
await this.storage.clearDiscoveryState(this.serverUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
OAuthDiscoveryState,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { OAuthClientRegistrationKind } from "./types.js";
|
||||
|
||||
|
|
@ -12,12 +13,23 @@ import type { OAuthClientRegistrationKind } from "./types.js";
|
|||
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 {
|
||||
|
|
@ -29,11 +41,16 @@ export interface OAuthStorage {
|
|||
load(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get client information (preregistered or dynamically registered)
|
||||
* 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>;
|
||||
|
||||
/**
|
||||
|
|
@ -41,6 +58,7 @@ export interface OAuthStorage {
|
|||
*/
|
||||
getClientRegistrationKind(
|
||||
serverUrl: string,
|
||||
issuer?: string,
|
||||
): Promise<OAuthClientRegistrationKind | undefined>;
|
||||
|
||||
/**
|
||||
|
|
@ -61,17 +79,25 @@ export interface OAuthStorage {
|
|||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear client information
|
||||
* 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
|
||||
* 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): Promise<OAuthTokens | undefined>;
|
||||
getTokens(
|
||||
serverUrl: string,
|
||||
issuer?: string,
|
||||
): Promise<OAuthTokens | undefined>;
|
||||
|
||||
/**
|
||||
* Save OAuth tokens
|
||||
|
|
@ -83,9 +109,10 @@ export interface OAuthStorage {
|
|||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear OAuth tokens
|
||||
* 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): Promise<void>;
|
||||
clearTokens(serverUrl: string, issuer?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get code verifier (for PKCE)
|
||||
|
|
@ -132,6 +159,29 @@ export interface OAuthStorage {
|
|||
*/
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
OAuthDiscoveryState,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
IdpSessionState,
|
||||
|
|
@ -15,19 +16,57 @@ import type {
|
|||
import type { OAuthPersistSnapshot } from "./oauth-persist.js";
|
||||
|
||||
/**
|
||||
* OAuth state for a single server
|
||||
* OAuth credentials bound to a single authorization-server `issuer` (SEP-2352).
|
||||
*
|
||||
* Client identifiers are unique to the AS that issued them (RFC 6749 §2.2), and
|
||||
* tokens minted by one AS must never be presented to another. The SDK v2 stamps
|
||||
* `issuer` on the objects it saves and passes `ctx.issuer` on every read/write;
|
||||
* this record is how the Inspector keys that state per authorization server so a
|
||||
* resource that migrates between ASes re-registers/re-authorizes cleanly instead
|
||||
* of reusing mismatched credentials.
|
||||
*/
|
||||
export interface ServerOAuthState {
|
||||
export interface IssuerBoundOAuthState {
|
||||
clientInformation?: OAuthClientInformation;
|
||||
/** Set when {@link clientInformation} is saved — DCR vs CIMD. */
|
||||
clientRegistrationKind?: OAuthClientRegistrationKind;
|
||||
preregisteredClientInformation?: OAuthClientInformation;
|
||||
tokens?: OAuthTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth state for a single server.
|
||||
*
|
||||
* Issuer-bound credentials (client info + tokens) live under {@link byIssuer},
|
||||
* keyed by AS `issuer` (SEP-2352). Per-server / per-flow state (code verifier,
|
||||
* discovered metadata, discovery state, static client info, EMA marker, and the
|
||||
* requested-scope seed) is issuer-independent and stays at the top level.
|
||||
*
|
||||
* The bare top-level {@link clientInformation} / {@link tokens} fields are the
|
||||
* **legacy unkeyed fallback**: a pre-1625 snapshot deserializes with credentials
|
||||
* here and no `issuer` stamp. Reads fall back to them when {@link byIssuer} has
|
||||
* no entry; the first issuer-stamped save promotes them into {@link byIssuer} and
|
||||
* clears the fallback (lazy migration — see `OAuthStorageBase`). New saves never
|
||||
* write the bare fields.
|
||||
*/
|
||||
export interface ServerOAuthState {
|
||||
/** Per-issuer credentials (SEP-2352). */
|
||||
byIssuer?: Record<string, IssuerBoundOAuthState>;
|
||||
/** Most-recently-saved issuer — answers ctx-less reads (per-request bearer token). */
|
||||
activeIssuer?: string;
|
||||
preregisteredClientInformation?: OAuthClientInformation;
|
||||
codeVerifier?: string;
|
||||
scope?: string;
|
||||
serverMetadata?: OAuthMetadata;
|
||||
/** RFC 9728/8414 discovery cache; persisted alongside {@link codeVerifier} (SEP-2352 callback-leg binding). */
|
||||
discoveryState?: OAuthDiscoveryState;
|
||||
/** Set when resource tokens were minted via EMA (legs 2–3). */
|
||||
enterpriseManaged?: boolean;
|
||||
|
||||
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
|
||||
clientInformation?: OAuthClientInformation;
|
||||
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
|
||||
clientRegistrationKind?: OAuthClientRegistrationKind;
|
||||
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
|
||||
tokens?: OAuthTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,12 +6,21 @@ import type {
|
|||
OAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/client";
|
||||
|
||||
// OAuth flow steps
|
||||
// OAuth flow steps. Extended for the 2026-07-28 authorization hardening:
|
||||
// `cimd_fetch` (SEP-991 client-id metadata document registration), `issuer_comparison`
|
||||
// (SEP-2352 authorization-server binding check), and `scope_step_up` (SEP-2350
|
||||
// accumulated-scope re-authorization).
|
||||
export type OAuthStep =
|
||||
| "metadata_discovery"
|
||||
| "client_registration"
|
||||
/** SEP-991 — registering via a Client ID Metadata Document instead of DCR. */
|
||||
| "cimd_fetch"
|
||||
/** SEP-2352 — comparing the resolved AS `issuer` against stored/discovered state. */
|
||||
| "issuer_comparison"
|
||||
| "authorization_redirect"
|
||||
| "authorization_code"
|
||||
/** SEP-2350 — re-authorizing with the accumulated (prior ∪ challenged) scope union. */
|
||||
| "scope_step_up"
|
||||
| "token_request"
|
||||
| "complete";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue