inspector/core/mcp/oauthManager.ts
Bob Dickinson 5de4e911e3
feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)
Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
2026-06-24 22:15:12 -04:00

538 lines
17 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.

/**
* Internal OAuth sub-manager for InspectorClient.
* Holds OAuth config, state machine, and guided state; orchestrates quick and guided flows.
* Not part of the public API; InspectorClient delegates to this module.
*/
import { BaseOAuthClientProvider } from "../auth/providers.js";
import type { AuthExecution, OAuthFlowState, OAuthStep } from "../auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
import { OAuthStateMachine } from "../auth/state-machine.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { parseOAuthState } from "../auth/utils.js";
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
import type { ClientConfig } from "../client/types.js";
import { EmaClientNotConfiguredError } from "../auth/ema/clientConfigError.js";
import {
completeEmaIdpAuthorizationAndMint,
refreshEmaResourceTokens,
startEmaIdpAuthorization,
trySilentEmaAuth,
type EmaFlowConfig,
} from "../auth/ema/emaFlow.js";
import {
buildOAuthConnectionState,
isServerOAuthConfigured,
protocolFromOAuthConfig,
} from "../auth/connection-state.js";
import type { OAuthConnectionState } from "../auth/types.js";
import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js";
import type {
InspectorClientEnvironment,
InspectorClientOptions,
} from "./types.js";
export type OAuthManagerConfig = NonNullable<InspectorClientOptions["oauth"]> &
NonNullable<InspectorClientEnvironment["oauth"]>;
export interface OAuthManagerParams {
getServerUrl: () => string;
effectiveAuthFetch: typeof fetch;
getEventTarget: () => EventTarget;
onBeforeOAuthRedirect?: (sessionId: string) => Promise<void>;
initialConfig: OAuthManagerConfig;
enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig };
/** Install-level EMA block (including when disabled) for user-facing errors. */
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
dispatchOAuthStepChange: (detail: {
step: OAuthStep;
previousStep: OAuthStep;
state: Partial<OAuthFlowState>;
}) => void;
dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void;
dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void;
dispatchOAuthError: (detail: { error: Error }) => void;
}
/**
* Internal manager for OAuth flow orchestration.
* InspectorClient creates this when oauth is configured and delegates all OAuth methods.
*/
export class OAuthManager {
private params: OAuthManagerParams;
private oauthConfig: OAuthManagerConfig;
private oauthStateMachine: OAuthStateMachine | null = null;
private oauthFlowState: OAuthFlowState | null = null;
constructor(params: OAuthManagerParams) {
this.params = params;
this.oauthConfig = { ...params.initialConfig };
}
setOAuthConfig(config: {
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
scope?: string;
enterpriseManaged?: boolean;
}): void {
this.oauthConfig = {
...this.oauthConfig,
...config,
} as OAuthManagerConfig;
}
private getServerUrl(): string {
return this.params.getServerUrl();
}
private async createOAuthProvider(
execution: AuthExecution,
): Promise<BaseOAuthClientProvider> {
if (
!this.oauthConfig.storage ||
!this.oauthConfig.redirectUrlProvider ||
!this.oauthConfig.navigation
) {
throw new Error(
"OAuth environment components (storage, navigation, redirectUrlProvider) are required.",
);
}
const serverUrl = this.getServerUrl();
const provider = new BaseOAuthClientProvider(
serverUrl,
{
storage: this.oauthConfig.storage,
redirectUrlProvider: this.oauthConfig.redirectUrlProvider,
navigation: this.oauthConfig.navigation,
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
},
execution,
);
provider.setEventTarget(this.params.getEventTarget());
if (this.oauthConfig.scope) {
await provider.saveScope(this.oauthConfig.scope);
}
if (this.oauthConfig.clientId) {
const clientInfo: OAuthClientInformation = {
client_id: this.oauthConfig.clientId,
...(this.oauthConfig.clientSecret && {
client_secret: this.oauthConfig.clientSecret,
}),
};
await provider.savePreregisteredClientInformation(clientInfo);
}
return provider;
}
/** Whether this connection uses enterprise-managed authorization (EMA). */
isEnterpriseManaged(): boolean {
return this.oauthConfig.enterpriseManaged === true;
}
private getEmaFlowConfig(): EmaFlowConfig {
if (!this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider) {
throw new Error(
"OAuth environment components (storage, redirectUrlProvider) are required.",
);
}
const idp = this.params.enterpriseManagedAuth?.idp;
if (!idp) {
const install = this.params.installEnterpriseManagedAuth;
throw new EmaClientNotConfiguredError(
install?.enabled === false && install.idp
? "disabled"
: "not_configured",
);
}
return {
serverUrl: this.getServerUrl(),
idp,
resourceClientId: this.oauthConfig.clientId,
resourceClientSecret: this.oauthConfig.clientSecret,
scope: this.oauthConfig.scope,
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl("quick"),
storage: this.oauthConfig.storage,
fetchFn: this.params.effectiveAuthFetch,
};
}
/** Attempt silent EMA (cached IdP session + legs 2–3). */
async trySilentEnterpriseManagedAuth(): Promise<boolean> {
if (!this.isEnterpriseManaged()) return false;
const result = await trySilentEmaAuth(this.getEmaFlowConfig());
if (result.status === "success") return true;
if (result.status === "mint_failed") throw result.error;
return false;
}
private async authenticateEnterpriseManaged(): Promise<URL | undefined> {
const config = this.getEmaFlowConfig();
const silent = await trySilentEmaAuth(config);
if (silent.status === "success") {
return undefined;
}
if (silent.status === "mint_failed") {
throw silent.error;
}
const authorizationUrl = await startEmaIdpAuthorization(config);
const stateParam = authorizationUrl.searchParams.get("state");
if (stateParam && this.params.onBeforeOAuthRedirect) {
const parsedState = parseOAuthState(stateParam);
if (parsedState?.authId) {
await this.params.onBeforeOAuthRedirect(parsedState.authId);
}
}
this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl);
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "authorization_code",
authorizationUrl,
};
return authorizationUrl;
}
async authenticate(): Promise<URL | undefined> {
if (this.isEnterpriseManaged()) {
return this.authenticateEnterpriseManaged();
}
const provider = await this.createOAuthProvider("quick");
const serverUrl = this.getServerUrl();
provider.clearCapturedAuthUrl();
const result = await auth(provider, {
serverUrl,
scope: provider.scope,
fetchFn: this.params.effectiveAuthFetch,
});
if (result === "AUTHORIZED") {
throw new Error(
"Unexpected: auth() returned AUTHORIZED without authorization code",
);
}
const capturedUrl = provider.getCapturedAuthUrl();
if (!capturedUrl) {
throw new Error("Failed to capture authorization URL");
}
const stateParam = capturedUrl.searchParams.get("state");
if (stateParam && this.params.onBeforeOAuthRedirect) {
const parsedState = parseOAuthState(stateParam);
if (parsedState?.authId) {
await this.params.onBeforeOAuthRedirect(parsedState.authId);
}
}
const clientInfo = await provider.clientInformation();
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "authorization_code",
authorizationUrl: capturedUrl,
oauthClientInfo: clientInfo ?? null,
};
return capturedUrl;
}
async beginGuidedAuth(): Promise<void> {
const provider = await this.createOAuthProvider("guided");
const serverUrl = this.getServerUrl();
this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE };
if (this.oauthConfig.clientId) {
this.oauthFlowState.oauthClientInfo = {
client_id: this.oauthConfig.clientId,
...(this.oauthConfig.clientSecret && {
client_secret: this.oauthConfig.clientSecret,
}),
};
}
this.oauthStateMachine = new OAuthStateMachine(
serverUrl,
provider,
(updates) => {
const state = this.oauthFlowState;
if (!state) throw new Error("OAuth state not initialized");
const previousStep = state.oauthStep;
this.oauthFlowState = { ...state, ...updates };
if (updates.oauthStep === "complete") {
this.oauthFlowState!.completedAt = Date.now();
}
const step = updates.oauthStep ?? previousStep;
this.params.dispatchOAuthStepChange({
step,
previousStep,
state: updates,
});
},
this.params.effectiveAuthFetch,
);
await this.oauthStateMachine.executeStep(this.oauthFlowState);
}
async runGuidedAuth(): Promise<URL | undefined> {
if (!this.oauthStateMachine || !this.oauthFlowState) {
await this.beginGuidedAuth();
}
const machine = this.oauthStateMachine;
if (!machine) {
throw new Error("Guided auth failed to initialize state");
}
while (true) {
const state = this.oauthFlowState;
if (!state) {
throw new Error("Guided auth failed to initialize state");
}
if (
state.oauthStep === "authorization_code" ||
state.oauthStep === "complete"
) {
break;
}
await machine.executeStep(state);
}
const state = this.oauthFlowState;
if (state?.oauthStep === "complete") {
return undefined;
}
if (!state?.authorizationUrl) {
throw new Error("Failed to generate authorization URL");
}
const stateParam = state.authorizationUrl.searchParams.get("state");
if (stateParam && this.params.onBeforeOAuthRedirect) {
const parsedState = parseOAuthState(stateParam);
if (parsedState?.authId) {
await this.params.onBeforeOAuthRedirect(parsedState.authId);
}
}
this.params.dispatchOAuthAuthorizationRequired({
url: state.authorizationUrl,
});
return state.authorizationUrl;
}
async setGuidedAuthorizationCode(
authorizationCode: string,
completeFlow: boolean = false,
): Promise<void> {
if (!this.oauthStateMachine || !this.oauthFlowState) {
throw new Error(
"Not in guided OAuth flow. Call beginGuidedAuth() first.",
);
}
const currentStep = this.oauthFlowState.oauthStep;
if (currentStep !== "authorization_code") {
throw new Error(
`Cannot set authorization code at step ${currentStep}. Expected step: authorization_code`,
);
}
this.oauthFlowState.authorizationCode = authorizationCode;
if (completeFlow) {
await this.oauthStateMachine.executeStep(this.oauthFlowState);
let step: OAuthStep = this.oauthFlowState.oauthStep;
while (step !== "complete") {
await this.oauthStateMachine.executeStep(this.oauthFlowState);
step = this.oauthFlowState.oauthStep;
}
if (!this.oauthFlowState.oauthTokens) {
throw new Error("Failed to exchange authorization code for tokens");
}
this.params.dispatchOAuthComplete({
tokens: this.oauthFlowState.oauthTokens,
});
} else {
this.params.dispatchOAuthStepChange({
step: this.oauthFlowState.oauthStep,
previousStep: this.oauthFlowState.oauthStep,
state: { authorizationCode },
});
}
}
async completeOAuthFlow(authorizationCode: string): Promise<void> {
try {
if (this.isEnterpriseManaged()) {
const config = this.getEmaFlowConfig();
const tokens = await completeEmaIdpAuthorizationAndMint(
config,
authorizationCode,
);
const completedAt = Date.now();
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "complete",
oauthTokens: tokens,
completedAt,
};
this.params.dispatchOAuthComplete({ tokens });
return;
}
if (this.oauthStateMachine && this.oauthFlowState) {
await this.setGuidedAuthorizationCode(authorizationCode, true);
} else {
const provider = await this.createOAuthProvider("quick");
const serverUrl = this.getServerUrl();
const result = await auth(provider, {
serverUrl,
authorizationCode,
fetchFn: this.params.effectiveAuthFetch,
});
if (result !== "AUTHORIZED") {
throw new Error(
`Expected AUTHORIZED after providing authorization code, got: ${result}`,
);
}
const tokens = await provider.tokens();
if (!tokens) {
throw new Error("Failed to retrieve tokens after authorization");
}
const clientInfo = await provider.clientInformation();
const completedAt = Date.now();
this.oauthFlowState = this.oauthFlowState
? {
...this.oauthFlowState,
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
completedAt,
}
: {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
completedAt,
};
this.params.dispatchOAuthComplete({ tokens });
}
} catch (error) {
this.params.dispatchOAuthError({
error: error instanceof Error ? error : new Error(String(error)),
});
throw error;
}
}
async getOAuthTokens(): Promise<OAuthTokens | undefined> {
if (this.oauthFlowState?.oauthTokens) {
return this.oauthFlowState.oauthTokens;
}
const provider = await this.createOAuthProvider("quick");
try {
return await provider.tokens();
} catch {
return undefined;
}
}
clearOAuthTokens(): void {
if (!this.oauthConfig?.storage) {
return;
}
const serverUrl = this.getServerUrl();
this.oauthConfig.storage.clear(serverUrl);
this.oauthFlowState = null;
this.oauthStateMachine = null;
}
async isOAuthAuthorized(): Promise<boolean> {
const tokens = await this.getOAuthTokens();
return tokens !== undefined;
}
getOAuthFlowState(): OAuthFlowState | undefined {
return this.oauthFlowState ? { ...this.oauthFlowState } : undefined;
}
getOAuthFlowStep(): OAuthStep | undefined {
return this.oauthFlowState?.oauthStep;
}
/**
* Persisted OAuth authorization snapshot for this server (storage + config).
* Returns undefined when OAuth is not configured for the server.
*/
async getOAuthState(): Promise<OAuthConnectionState | undefined> {
if (!isServerOAuthConfigured(this.oauthConfig)) {
return undefined;
}
return buildOAuthConnectionState({
serverUrl: this.getServerUrl(),
protocol: protocolFromOAuthConfig(this.oauthConfig),
configuredScope: this.oauthConfig.scope,
enterpriseManagedAuth: this.params.enterpriseManagedAuth,
storage: this.oauthConfig.storage!,
flowState: this.oauthFlowState ?? undefined,
});
}
async proceedOAuthStep(): Promise<void> {
if (!this.oauthStateMachine || !this.oauthFlowState) {
throw new Error(
"Not in guided OAuth flow. Call authenticateGuided() first.",
);
}
await this.oauthStateMachine.executeStep(this.oauthFlowState);
}
/**
* Re-run EMA legs 2–3 when resource tokens expire but IdP session remains valid.
*/
async refreshEnterpriseManagedTokens(): Promise<boolean> {
if (!this.isEnterpriseManaged()) return false;
const tokens = await refreshEmaResourceTokens(this.getEmaFlowConfig());
return tokens !== undefined;
}
/**
* Create an OAuth provider for transport auth (connect()).
* Used only when isHttpOAuthConfig() is true.
*/
async createOAuthProviderForTransport(): Promise<
BaseOAuthClientProvider | EmaTransportOAuthProvider
> {
const provider = await this.createOAuthProvider("quick");
if (this.isEnterpriseManaged()) {
return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig());
}
return provider;
}
}