inspector/core/auth/providers.ts
cliffhall 4789cb954e fix: preserve CIMD registration provenance across SDK issuer binding (#2242)
`BaseOAuthClientProvider.saveClientInformation` treated every save without an
explicit `registrationKind` as DCR. The SDK reaches it from three places in
`auth()` and only one is a dynamic registration, so the issuer-binding write
overwrote the `cimd` provenance our own pre-registration had just stored — and
Connection Info reported `Dynamic (DCR)` for a connection that never issued a
`POST /oauth/register`.

Recover the kind instead: a `client_id` equal to the configured
`clientMetadataUrl` is CIMD (which also covers the SDK's own CIMD write), and
otherwise a stored registration with the same `client_id` carries its recorded
kind forward. Matching on `client_id` keeps stale CIMD provenance from leaking
onto a later DCR registration for the same server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 00:04:40 -04:00

502 lines
18 KiB
TypeScript

import type {
OAuthClientProvider,
OAuthClientInformationContext,
} from "@modelcontextprotocol/client";
import type {
OAuthClientInformation,
OAuthClientMetadata,
OAuthTokens,
OAuthMetadata,
OAuthDiscoveryState,
} from "@modelcontextprotocol/client";
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
import { generateOAuthState } from "./utils.js";
import { applyAuthorizationParams } from "./authorizationParams.js";
import { scopeForDeclinedRefreshGrant } from "./scopes.js";
/**
* Redirect URL provider. Returns the redirect URL for OAuth flows.
* Caller populates the URL before authenticate() (e.g. from callback server).
*/
export interface RedirectUrlProvider {
getRedirectUrl(): string;
}
/**
* Mutable redirect URL provider for TUI/CLI. Caller sets redirectUrl
* before authenticate().
*/
export class MutableRedirectUrlProvider implements RedirectUrlProvider {
redirectUrl = "";
getRedirectUrl(): string {
return this.redirectUrl;
}
}
/**
* Navigation handler interface
* Handles navigation to authorization URLs
*/
export interface OAuthNavigation {
/**
* Navigate to the authorization URL
* @param authorizationUrl - The OAuth authorization URL
*/
navigateToAuthorization(authorizationUrl: URL): void;
}
export type OAuthNavigationCallback = (
authorizationUrl: URL,
) => void | Promise<void>;
/**
* Callback navigation handler
* Invokes the provided callback when navigation is requested.
* The caller always handles navigation.
*/
export class CallbackNavigation implements OAuthNavigation {
private authorizationUrl: URL | null = null;
private callback: OAuthNavigationCallback;
constructor(callback: OAuthNavigationCallback) {
this.callback = callback;
}
navigateToAuthorization(authorizationUrl: URL): void {
this.authorizationUrl = authorizationUrl;
const result = this.callback(authorizationUrl);
if (result instanceof Promise) {
void result;
}
}
getAuthorizationUrl(): URL | null {
return this.authorizationUrl;
}
}
/**
* Console navigation handler
* Prints the authorization URL to console, optionally invokes an extra callback.
*/
export class ConsoleNavigation extends CallbackNavigation {
constructor(callback?: OAuthNavigationCallback) {
super((url) => {
console.log(`Please navigate to: ${url.href}`);
return callback?.(url);
});
}
}
/**
* Config passed to BaseOAuthClientProvider. Provider assigns to members and
* accesses as needed.
*/
export type OAuthProviderConfig = {
storage: OAuthStorage;
redirectUrlProvider: RedirectUrlProvider;
navigation: OAuthNavigation;
clientMetadataUrl?: string;
/**
* Per-server custom authorization-request parameters (#2018), merged into the
* finished authorize URL in {@link BaseOAuthClientProvider.redirectToAuthorization}.
* Reserved (protocol-critical) keys are dropped with a warning — see
* `authorizationParams.ts`. Authorization request only; the token request is
* unaffected.
*/
authorizationParams?: Record<string, string>;
/**
* Whether to declare the `refresh_token` grant in the registered client
* metadata (#2068). Defaults to `true`; set `false` to register (and
* authorize) as an authorization-code-only client.
*
* This is not merely cosmetic. The SDK's `determineScope()` appends
* `offline_access` to the effective scope whenever the authorization server
* advertises it **and** the client metadata declares `refresh_token`, and
* `startAuthorization()` then appends `prompt=consent` whenever
* `offline_access` is in scope. Against Microsoft Entra ID, that forced
* consent prompt routes a non-admin user into the admin-consent workflow and
* fails with `AADSTS90094` even after a tenant admin has consented.
*
* It removes that *automatic* augmentation only. `startAuthorization()` reads
* the scope, never `grant_types`, so an `offline_access` the caller passed in
* `scope` — or one the resource advertises when `scope` is unset, which
* `determineScope` falls back to — still produces the prompt. This is not on
* its own a guarantee that `prompt=consent` is absent.
*/
requestRefreshToken?: boolean;
/**
* The scope configured for this server (mcp.json / Server Settings), as
* distinct from the scope carried in OAuth storage after a previous grant.
*
* Used only to decide whether an `offline_access` in the effective scope was
* *asked for* or merely inherited — see {@link scopeForDeclinedRefreshGrant}.
* An explicitly configured one is honored; an inherited one is dropped from
* the request when {@link requestRefreshToken} is false. (#2068)
*/
configuredScope?: string;
};
/**
* Base OAuth client provider
* Implements common OAuth provider functionality.
* Use with injected storage, redirect URL provider, and navigation.
*/
export class BaseOAuthClientProvider implements OAuthClientProvider {
private capturedAuthUrl: URL | null = null;
private eventTarget: EventTarget | null = null;
private suppressAuthorizationNavigation = false;
/** Cached after {@link prepareForAuth} for sync SDK `clientMetadata.scope`. */
private cachedScope: string | undefined;
protected serverUrl: string;
protected storage: OAuthStorage;
protected redirectUrlProvider: RedirectUrlProvider;
protected navigation: OAuthNavigation;
public clientMetadataUrl?: string;
/** Custom authorization-request parameters (#2018). Authorize URL only. */
protected authorizationParams?: Record<string, string>;
/** Declare the `refresh_token` grant in {@link clientMetadata} (#2068). */
protected requestRefreshToken: boolean;
/** Server-configured scope, for the #2068 `offline_access` distinction. */
protected configuredScope?: string;
constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) {
this.serverUrl = serverUrl;
this.storage = oauthConfig.storage;
this.redirectUrlProvider = oauthConfig.redirectUrlProvider;
this.navigation = oauthConfig.navigation;
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
this.authorizationParams = oauthConfig.authorizationParams;
this.requestRefreshToken = oauthConfig.requestRefreshToken ?? true;
this.configuredScope = oauthConfig.configuredScope;
}
/**
* Load persisted scope into {@link cachedScope} before SDK `auth()` (which
* reads {@link clientMetadata.scope} synchronously).
*/
async prepareForAuth(): Promise<void> {
this.cachedScope = await this.storage.getScope(this.serverUrl);
}
/**
* Set the event target for dispatching oauthAuthorizationRequired events
*/
setEventTarget(eventTarget: EventTarget): void {
this.eventTarget = eventTarget;
}
/**
* Get the captured authorization URL (for return value)
*/
getCapturedAuthUrl(): URL | null {
return this.capturedAuthUrl;
}
/**
* Clear the captured authorization URL
*/
clearCapturedAuthUrl(): void {
this.capturedAuthUrl = null;
}
/** Capture authorize URL without navigating (step-up confirmation modal). */
setSuppressAuthorizationNavigation(suppress: boolean): void {
this.suppressAuthorizationNavigation = suppress;
}
get scope(): string | undefined {
// #2068: filter at the point of *request*, not in storage. Covers the two
// readers that go through this getter — `clientMetadata.scope` below, and
// the `scope:` argument `OAuthManager.authenticate` hands to the SDK.
//
// It is NOT the only request path: mid-session `insufficient_scope` step-up
// builds its union from raw storage and passes it to `mcpAuth` directly,
// never reading this getter. `OAuthManager.handleAuthChallenge` applies the
// same filter there. Both call `scopeForDeclinedRefreshGrant`, so the rule
// lives in one place even though it has to be applied twice.
if (!this.requestRefreshToken) {
return scopeForDeclinedRefreshGrant(
this.cachedScope,
this.configuredScope,
);
}
return this.cachedScope;
}
get redirectUrl(): string {
return this.redirectUrlProvider.getRedirectUrl();
}
get redirect_uris(): string[] {
return [this.redirectUrl];
}
get clientMetadata(): OAuthClientMetadata {
const metadata: OAuthClientMetadata = {
redirect_uris: this.redirect_uris,
token_endpoint_auth_method: "none",
// #2068: `refresh_token` is declared by default, and dropped when the
// server's "Request refresh token" setting is off — which stops the SDK
// *adding* `offline_access` (and so the `prompt=consent` it forces). It
// does not remove an `offline_access` that reaches the scope another way.
// See `OAuthProviderConfig.requestRefreshToken`.
grant_types: this.requestRefreshToken
? ["authorization_code", "refresh_token"]
: ["authorization_code"],
response_types: ["code"],
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,
// not as part of clientMetadata. The SDK handles CIMD separately.
return metadata;
}
state(): string | Promise<string> {
return generateOAuthState();
}
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,
);
if (preregistered) {
return preregistered;
}
return await this.storage.getClientInformation(
this.serverUrl,
false,
ctx?.issuer,
);
}
async saveClientInformation(
clientInformation: OAuthClientInformation,
// SDK v2's `OAuthClientProvider.saveClientInformation` passes an
// `OAuthClientInformationContext` ({ issuer }); our own DCR/CIMD callers
// pass `SaveClientInformationOptions` ({ registrationKind }). Accept either
// and read whichever keys are present: the SDK supplies `issuer` (SEP-2352
// per-AS keying) and no kind — `resolveSdkRegistrationKind` recovers it —
// while our callers supply the registration kind and no issuer yet.
options?: SaveClientInformationOptions | OAuthClientInformationContext,
): Promise<void> {
const issuer = options && "issuer" in options ? options.issuer : undefined;
const registrationKind =
options && "registrationKind" in options
? options.registrationKind
: await this.resolveSdkRegistrationKind(clientInformation, issuer);
await this.storage.saveClientInformation(
this.serverUrl,
clientInformation,
{
registrationKind,
issuer,
},
);
}
/**
* Resolve the registration kind for a save that carries no explicit one — that
* is, one the SDK made. Three SDK call sites reach here: back-stamping an
* existing registration with its `issuer`, the SDK's own CIMD write (whose
* `client_id` *is* the metadata-document URL), and a real dynamic
* registration. Only the last is `"dcr"`, so defaulting every unstamped save
* to it relabels a CIMD registration `Dynamic (DCR)` in Connection Info the
* moment the SDK binds it to an issuer — reported as #2242, where no
* `POST /register` was ever made.
*
* `client_id` is what tells the cases apart, so match on it rather than on the
* stored kind alone: a DCR `client_id` is minted by the authorization server,
* so a later DCR registration for the same server cannot inherit the earlier
* CIMD provenance.
*/
private async resolveSdkRegistrationKind(
clientInformation: OAuthClientInformation,
issuer: string | undefined,
): Promise<SaveClientInformationOptions["registrationKind"]> {
const clientMetadataUrl = this.clientMetadataUrl?.trim();
if (
clientMetadataUrl &&
clientInformation.client_id === clientMetadataUrl
) {
return "cimd";
}
// Falls back to the unkeyed slot our own pre-registration wrote, since the
// issuer slot does not exist yet on the save that creates it. Reading it
// covers a CIMD registration whose `clientMetadataUrl` config has since
// been cleared, so the provenance is not silently demoted.
const stored = await this.storage.getClientInformation(
this.serverUrl,
false,
issuer,
);
if (!stored || stored.client_id !== clientInformation.client_id) {
return "dcr";
}
const storedKind = await this.storage.getClientRegistrationKind(
this.serverUrl,
issuer,
);
// `"static"` lives in the preregistered slot, never this one, so `"cimd"`
// is the only kind worth carrying forward.
return storedKind === "cimd" ? "cimd" : "dcr";
}
async saveScope(scope: string | undefined): Promise<void> {
await this.storage.saveScope(this.serverUrl, scope);
this.cachedScope = scope;
}
async savePreregisteredClientInformation(
clientInformation: OAuthClientInformation,
): Promise<void> {
await this.storage.savePreregisteredClientInformation(
this.serverUrl,
clientInformation,
);
}
async tokens(
ctx?: OAuthClientInformationContext,
): Promise<OAuthTokens | undefined> {
return await this.storage.getTokens(this.serverUrl, ctx?.issuer);
}
async saveTokens(
tokens: OAuthTokens,
ctx?: OAuthClientInformationContext,
): Promise<void> {
await this.storage.saveTokens(this.serverUrl, tokens, {
issuer: ctx?.issuer,
});
}
redirectToAuthorization(authorizationUrl: URL): void {
// #2018: the SDK builds the authorize URL and offers no hook for extra
// parameters, so the per-server ones are merged here — the one seam that
// sees the finished URL before navigation. Everything downstream (the
// captured URL used by the step-up modal, the event, the navigation) uses
// the merged URL so all three agree on what was actually requested.
this.performAuthorizationRedirect(
applyAuthorizationParams(authorizationUrl, this.authorizationParams),
);
}
/**
* Redirect to an authorize URL belonging to a **different** authorization
* server than the one this provider was configured for, so the per-server
* custom parameters (#2018) MUST NOT be applied.
*
* The only caller is the enterprise-managed (EMA) transport provider, which
* discards the resource AS URL and sends the user to the enterprise IdP
* instead. Parameters a user configured for their MCP server's authorization
* server — a `kc_idp_hint`, an `audience` — are meaningless and potentially
* flow-breaking on the IdP's, and appending them there would leak per-server
* config across an authorization-server boundary.
*/
redirectToExternalAuthorization(authorizationUrl: URL): void {
this.performAuthorizationRedirect(authorizationUrl);
}
/** Capture, announce, and navigate to a finished authorize URL. */
private performAuthorizationRedirect(authorizationUrl: URL): void {
// Capture URL for return value
this.capturedAuthUrl = authorizationUrl;
if (!this.suppressAuthorizationNavigation) {
if (this.eventTarget) {
this.eventTarget.dispatchEvent(
new CustomEvent("oauthAuthorizationRequired", {
detail: { url: authorizationUrl },
}),
);
}
this.navigation.navigateToAuthorization(authorizationUrl);
}
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
await this.storage.saveCodeVerifier(this.serverUrl, codeVerifier);
}
async codeVerifier(): Promise<string> {
const verifier = await this.storage.getCodeVerifier(this.serverUrl);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
return verifier;
}
async clear(): Promise<void> {
await this.storage.clear(this.serverUrl);
}
async getServerMetadata(): Promise<OAuthMetadata | null> {
return this.storage.getServerMetadata(this.serverUrl);
}
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;
}
}
}