inspector/core/auth/providers.ts
cliffhall b25ece8ded feat: per-server custom OAuth authorization-request parameters (#2018)
Real deployments gate IdP selection, prompt behavior, or audience on a query
parameter the OAuth/OIDC core specs never standardized (Keycloak's
`kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's
`audience`), and none of them could be driven from the Inspector.

The Inspector never builds the authorize URL — the SDK's `auth()` does, and it
exposes no hook for extra parameters — so the merge happens at the one seam
that sees the finished URL: `BaseOAuthClientProvider.redirectToAuthorization`.
It lives in `core/`, so web, CLI, and TUI all inherit it.

- `core/auth/authorizationParams.ts`: the reserved-key list (`client_id`,
  `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`,
  `response_type`, `scope`, `state`), the key validator the form renders
  inline, and the merge itself. Reserved keys are dropped with a warning
  rather than silently overriding PKCE / the state binding / RFC 8707;
  blank-key rows are skipped; the caller's URL is never mutated.
- Applied to the authorization request only — nothing wires it into the token
  request.
- Persisted as `oauth.authorizationParams` (a string record) on the per-server
  config beside `scopes`, edited as key/value rows in Server Settings ->
  Authorization ("Additional authorization parameters"), with an inline error
  per offending row plus a section-level warning.

- The enterprise-managed (EMA) leg redirects to a *different* authorization
  server (the enterprise IdP), so it goes through the explicit
  `redirectToExternalAuthorization` entry point, which skips the merge — a
  `kc_idp_hint` or `audience` meant for the MCP server's AS must never be
  appended to the IdP's authorize URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 19:31:41 -04:00

396 lines
13 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";
/**
* 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>;
};
/**
* 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>;
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;
}
/**
* 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 {
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",
grant_types: ["authorization_code", "refresh_token"],
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 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,
},
);
}
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;
}
}
}