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).
This commit is contained in:
parent
5ee347571f
commit
5de4e911e3
9 changed files with 7585 additions and 595 deletions
|
|
@ -22,3 +22,8 @@ export function getServerType(config: MCPServerConfig): ServerType {
|
|||
`Invalid server type: ${type}. Valid types are: stdio, sse, streamable-http`,
|
||||
);
|
||||
}
|
||||
|
||||
/** OAuth and enterprise-managed auth apply only to remote HTTP-based transports. */
|
||||
export function isOAuthCapableServerType(type: ServerType): boolean {
|
||||
return type === "sse" || type === "streamable-http";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export type {
|
|||
// Re-export type-safe event target types for consumers
|
||||
export type { InspectorClientEventMap } from "./inspectorClientEventTarget.js";
|
||||
|
||||
export { getServerType } from "./config.js";
|
||||
export { getServerType, isOAuthCapableServerType } from "./config.js";
|
||||
|
||||
// Re-export types used by consumers
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ import {
|
|||
UrlElicitationLoopError,
|
||||
} from "./urlElicitation.js";
|
||||
import { ToolCallCancelledError } from "./toolCallCancelledError.js";
|
||||
import type { AuthGuidedState, OAuthStep } from "../auth/types.js";
|
||||
import type { OAuthConnectionState, OAuthFlowState, OAuthStep } from "../auth/types.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
||||
import { createFetchTracker } from "./fetchTracking.js";
|
||||
|
|
@ -306,6 +306,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return Promise.resolve();
|
||||
},
|
||||
initialConfig: oauthConfig,
|
||||
enterpriseManagedAuth: options.enterpriseManagedAuth,
|
||||
installEnterpriseManagedAuth: options.installEnterpriseManagedAuth,
|
||||
dispatchOAuthStepChange: (detail) =>
|
||||
this.dispatchTypedEvent("oauthStepChange", detail),
|
||||
dispatchOAuthComplete: (detail) =>
|
||||
|
|
@ -362,6 +364,11 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
},
|
||||
};
|
||||
}
|
||||
if (options.oauth?.enterpriseManaged) {
|
||||
capabilities.extensions = {
|
||||
"io.modelcontextprotocol/enterprise-managed-authorization": {},
|
||||
};
|
||||
}
|
||||
if (Object.keys(capabilities).length > 0) {
|
||||
clientOptions.capabilities = capabilities;
|
||||
}
|
||||
|
|
@ -702,7 +709,21 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
};
|
||||
const oauthManager = this.oauthManager;
|
||||
if (this.isHttpOAuthConfig() && oauthManager) {
|
||||
if (oauthManager.isEnterpriseManaged()) {
|
||||
await oauthManager.trySilentEnterpriseManagedAuth();
|
||||
}
|
||||
const provider = await oauthManager.createOAuthProviderForTransport();
|
||||
if (oauthManager.isEnterpriseManaged()) {
|
||||
const tokens = await provider.tokens();
|
||||
if (!tokens?.access_token) {
|
||||
const err = new Error(
|
||||
"Unauthorized: EMA resource access token unavailable",
|
||||
) as Error & { status?: number; code?: number };
|
||||
err.status = 401;
|
||||
err.code = 401;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
transportOptions.authProvider = provider;
|
||||
}
|
||||
const { transport: baseTransport } = this.transportClientFactory(
|
||||
|
|
@ -2655,7 +2676,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
* Initiates OAuth flow using SDK's auth() function (normal mode)
|
||||
* Can be called directly by user or automatically triggered by 401 errors
|
||||
*/
|
||||
async authenticate(): Promise<URL> {
|
||||
async authenticate(): Promise<URL | undefined> {
|
||||
return this.ensureOAuthManager().authenticate();
|
||||
}
|
||||
|
||||
|
|
@ -2733,17 +2754,30 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get current OAuth state machine state (for guided mode)
|
||||
* In-memory OAuth flow snapshot (quick or guided). Undefined when no flow
|
||||
* has run on this client instance; use {@link getOAuthState} for persisted
|
||||
* authorization state.
|
||||
*/
|
||||
getOAuthState(): AuthGuidedState | undefined {
|
||||
return this.oauthManager?.getOAuthState();
|
||||
getOAuthFlowState(): OAuthFlowState | undefined {
|
||||
return this.oauthManager?.getOAuthFlowState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current OAuth step (for guided mode)
|
||||
* Current step when an OAuth flow is active (quick or guided).
|
||||
*/
|
||||
getOAuthStep(): OAuthStep | undefined {
|
||||
return this.oauthManager?.getOAuthStep();
|
||||
getOAuthFlowStep(): OAuthStep | undefined {
|
||||
return this.oauthManager?.getOAuthFlowStep();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted OAuth authorization snapshot for this HTTP server (storage +
|
||||
* config). Undefined for stdio transports or when OAuth is not configured.
|
||||
*/
|
||||
async getOAuthState(): Promise<OAuthConnectionState | undefined> {
|
||||
if (!this.isHttpOAuthConfig() || !this.oauthManager) {
|
||||
return undefined;
|
||||
}
|
||||
return this.oauthManager.getOAuthState();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
|
|||
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
|
||||
import type { JsonValue } from "../json/jsonUtils.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type { OAuthStep, AuthGuidedState } from "../auth/types.js";
|
||||
import type { OAuthStep, OAuthFlowState } from "../auth/types.js";
|
||||
|
||||
/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */
|
||||
export type TaskWithOptionalCreatedAt = Omit<Task, "createdAt"> & {
|
||||
|
|
@ -149,7 +149,7 @@ export interface InspectorClientEventMap {
|
|||
oauthStepChange: {
|
||||
step: OAuthStep;
|
||||
previousStep: OAuthStep;
|
||||
state: Partial<AuthGuidedState>;
|
||||
state: Partial<OAuthFlowState>;
|
||||
};
|
||||
oauthComplete: { tokens: OAuthTokens };
|
||||
oauthAuthorizationRequired: { url: URL };
|
||||
|
|
|
|||
|
|
@ -1,20 +1,37 @@
|
|||
/**
|
||||
* Internal OAuth sub-manager for InspectorClient.
|
||||
* Holds OAuth config, state machine, and guided state; orchestrates normal and guided flows.
|
||||
* 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 { AuthGuidedState, OAuthStep } from "../auth/types.js";
|
||||
import { EMPTY_GUIDED_STATE } from "../auth/types.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 {
|
||||
InspectorClientOptions,
|
||||
InspectorClientEnvironment,
|
||||
InspectorClientOptions,
|
||||
} from "./types.js";
|
||||
|
||||
export type OAuthManagerConfig = NonNullable<InspectorClientOptions["oauth"]> &
|
||||
|
|
@ -26,10 +43,13 @@ export interface OAuthManagerParams {
|
|||
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<AuthGuidedState>;
|
||||
state: Partial<OAuthFlowState>;
|
||||
}) => void;
|
||||
dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void;
|
||||
dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void;
|
||||
|
|
@ -44,7 +64,7 @@ export class OAuthManager {
|
|||
private params: OAuthManagerParams;
|
||||
private oauthConfig: OAuthManagerConfig;
|
||||
private oauthStateMachine: OAuthStateMachine | null = null;
|
||||
private oauthState: AuthGuidedState | null = null;
|
||||
private oauthFlowState: OAuthFlowState | null = null;
|
||||
|
||||
constructor(params: OAuthManagerParams) {
|
||||
this.params = params;
|
||||
|
|
@ -56,6 +76,7 @@ export class OAuthManager {
|
|||
clientSecret?: string;
|
||||
clientMetadataUrl?: string;
|
||||
scope?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
}): void {
|
||||
this.oauthConfig = {
|
||||
...this.oauthConfig,
|
||||
|
|
@ -68,7 +89,7 @@ export class OAuthManager {
|
|||
}
|
||||
|
||||
private async createOAuthProvider(
|
||||
mode: "normal" | "guided",
|
||||
execution: AuthExecution,
|
||||
): Promise<BaseOAuthClientProvider> {
|
||||
if (
|
||||
!this.oauthConfig.storage ||
|
||||
|
|
@ -89,7 +110,7 @@ export class OAuthManager {
|
|||
navigation: this.oauthConfig.navigation,
|
||||
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
|
||||
},
|
||||
mode,
|
||||
execution,
|
||||
);
|
||||
|
||||
provider.setEventTarget(this.params.getEventTarget());
|
||||
|
|
@ -111,8 +132,82 @@ export class OAuthManager {
|
|||
return provider;
|
||||
}
|
||||
|
||||
async authenticate(): Promise<URL> {
|
||||
const provider = await this.createOAuthProvider("normal");
|
||||
/** 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();
|
||||
|
|
@ -143,9 +238,9 @@ export class OAuthManager {
|
|||
}
|
||||
|
||||
const clientInfo = await provider.clientInformation();
|
||||
this.oauthState = {
|
||||
...EMPTY_GUIDED_STATE,
|
||||
authType: "normal",
|
||||
this.oauthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
execution: "quick",
|
||||
oauthStep: "authorization_code",
|
||||
authorizationUrl: capturedUrl,
|
||||
oauthClientInfo: clientInfo ?? null,
|
||||
|
|
@ -157,9 +252,9 @@ export class OAuthManager {
|
|||
const provider = await this.createOAuthProvider("guided");
|
||||
const serverUrl = this.getServerUrl();
|
||||
|
||||
this.oauthState = { ...EMPTY_GUIDED_STATE };
|
||||
this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE };
|
||||
if (this.oauthConfig.clientId) {
|
||||
this.oauthState.oauthClientInfo = {
|
||||
this.oauthFlowState.oauthClientInfo = {
|
||||
client_id: this.oauthConfig.clientId,
|
||||
...(this.oauthConfig.clientSecret && {
|
||||
client_secret: this.oauthConfig.clientSecret,
|
||||
|
|
@ -170,12 +265,12 @@ export class OAuthManager {
|
|||
serverUrl,
|
||||
provider,
|
||||
(updates) => {
|
||||
const state = this.oauthState;
|
||||
const state = this.oauthFlowState;
|
||||
if (!state) throw new Error("OAuth state not initialized");
|
||||
const previousStep = state.oauthStep;
|
||||
this.oauthState = { ...state, ...updates };
|
||||
this.oauthFlowState = { ...state, ...updates };
|
||||
if (updates.oauthStep === "complete") {
|
||||
this.oauthState!.completedAt = Date.now();
|
||||
this.oauthFlowState!.completedAt = Date.now();
|
||||
}
|
||||
const step = updates.oauthStep ?? previousStep;
|
||||
this.params.dispatchOAuthStepChange({
|
||||
|
|
@ -187,11 +282,11 @@ export class OAuthManager {
|
|||
this.params.effectiveAuthFetch,
|
||||
);
|
||||
|
||||
await this.oauthStateMachine.executeStep(this.oauthState);
|
||||
await this.oauthStateMachine.executeStep(this.oauthFlowState);
|
||||
}
|
||||
|
||||
async runGuidedAuth(): Promise<URL | undefined> {
|
||||
if (!this.oauthStateMachine || !this.oauthState) {
|
||||
if (!this.oauthStateMachine || !this.oauthFlowState) {
|
||||
await this.beginGuidedAuth();
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +296,7 @@ export class OAuthManager {
|
|||
}
|
||||
|
||||
while (true) {
|
||||
const state = this.oauthState;
|
||||
const state = this.oauthFlowState;
|
||||
if (!state) {
|
||||
throw new Error("Guided auth failed to initialize state");
|
||||
}
|
||||
|
|
@ -214,7 +309,7 @@ export class OAuthManager {
|
|||
await machine.executeStep(state);
|
||||
}
|
||||
|
||||
const state = this.oauthState;
|
||||
const state = this.oauthFlowState;
|
||||
if (state?.oauthStep === "complete") {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -241,39 +336,39 @@ export class OAuthManager {
|
|||
authorizationCode: string,
|
||||
completeFlow: boolean = false,
|
||||
): Promise<void> {
|
||||
if (!this.oauthStateMachine || !this.oauthState) {
|
||||
if (!this.oauthStateMachine || !this.oauthFlowState) {
|
||||
throw new Error(
|
||||
"Not in guided OAuth flow. Call beginGuidedAuth() first.",
|
||||
);
|
||||
}
|
||||
const currentStep = this.oauthState.oauthStep;
|
||||
const currentStep = this.oauthFlowState.oauthStep;
|
||||
if (currentStep !== "authorization_code") {
|
||||
throw new Error(
|
||||
`Cannot set authorization code at step ${currentStep}. Expected step: authorization_code`,
|
||||
);
|
||||
}
|
||||
|
||||
this.oauthState.authorizationCode = authorizationCode;
|
||||
this.oauthFlowState.authorizationCode = authorizationCode;
|
||||
|
||||
if (completeFlow) {
|
||||
await this.oauthStateMachine.executeStep(this.oauthState);
|
||||
let step: OAuthStep = this.oauthState.oauthStep;
|
||||
await this.oauthStateMachine.executeStep(this.oauthFlowState);
|
||||
let step: OAuthStep = this.oauthFlowState.oauthStep;
|
||||
while (step !== "complete") {
|
||||
await this.oauthStateMachine.executeStep(this.oauthState);
|
||||
step = this.oauthState.oauthStep;
|
||||
await this.oauthStateMachine.executeStep(this.oauthFlowState);
|
||||
step = this.oauthFlowState.oauthStep;
|
||||
}
|
||||
|
||||
if (!this.oauthState.oauthTokens) {
|
||||
if (!this.oauthFlowState.oauthTokens) {
|
||||
throw new Error("Failed to exchange authorization code for tokens");
|
||||
}
|
||||
|
||||
this.params.dispatchOAuthComplete({
|
||||
tokens: this.oauthState.oauthTokens,
|
||||
tokens: this.oauthFlowState.oauthTokens,
|
||||
});
|
||||
} else {
|
||||
this.params.dispatchOAuthStepChange({
|
||||
step: this.oauthState.oauthStep,
|
||||
previousStep: this.oauthState.oauthStep,
|
||||
step: this.oauthFlowState.oauthStep,
|
||||
previousStep: this.oauthFlowState.oauthStep,
|
||||
state: { authorizationCode },
|
||||
});
|
||||
}
|
||||
|
|
@ -281,10 +376,28 @@ export class OAuthManager {
|
|||
|
||||
async completeOAuthFlow(authorizationCode: string): Promise<void> {
|
||||
try {
|
||||
if (this.oauthStateMachine && this.oauthState) {
|
||||
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("normal");
|
||||
const provider = await this.createOAuthProvider("quick");
|
||||
const serverUrl = this.getServerUrl();
|
||||
|
||||
const result = await auth(provider, {
|
||||
|
|
@ -306,17 +419,17 @@ export class OAuthManager {
|
|||
|
||||
const clientInfo = await provider.clientInformation();
|
||||
const completedAt = Date.now();
|
||||
this.oauthState = this.oauthState
|
||||
this.oauthFlowState = this.oauthFlowState
|
||||
? {
|
||||
...this.oauthState,
|
||||
...this.oauthFlowState,
|
||||
oauthStep: "complete",
|
||||
oauthTokens: tokens,
|
||||
oauthClientInfo: clientInfo ?? null,
|
||||
completedAt,
|
||||
}
|
||||
: {
|
||||
...EMPTY_GUIDED_STATE,
|
||||
authType: "normal",
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
execution: "quick",
|
||||
oauthStep: "complete",
|
||||
oauthTokens: tokens,
|
||||
oauthClientInfo: clientInfo ?? null,
|
||||
|
|
@ -334,11 +447,11 @@ export class OAuthManager {
|
|||
}
|
||||
|
||||
async getOAuthTokens(): Promise<OAuthTokens | undefined> {
|
||||
if (this.oauthState?.oauthTokens) {
|
||||
return this.oauthState.oauthTokens;
|
||||
if (this.oauthFlowState?.oauthTokens) {
|
||||
return this.oauthFlowState.oauthTokens;
|
||||
}
|
||||
|
||||
const provider = await this.createOAuthProvider("normal");
|
||||
const provider = await this.createOAuthProvider("quick");
|
||||
try {
|
||||
return await provider.tokens();
|
||||
} catch {
|
||||
|
|
@ -354,7 +467,7 @@ export class OAuthManager {
|
|||
const serverUrl = this.getServerUrl();
|
||||
this.oauthConfig.storage.clear(serverUrl);
|
||||
|
||||
this.oauthState = null;
|
||||
this.oauthFlowState = null;
|
||||
this.oauthStateMachine = null;
|
||||
}
|
||||
|
||||
|
|
@ -363,29 +476,63 @@ export class OAuthManager {
|
|||
return tokens !== undefined;
|
||||
}
|
||||
|
||||
getOAuthState(): AuthGuidedState | undefined {
|
||||
return this.oauthState ? { ...this.oauthState } : undefined;
|
||||
getOAuthFlowState(): OAuthFlowState | undefined {
|
||||
return this.oauthFlowState ? { ...this.oauthFlowState } : undefined;
|
||||
}
|
||||
|
||||
getOAuthStep(): OAuthStep | undefined {
|
||||
return this.oauthState?.oauthStep;
|
||||
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.oauthState) {
|
||||
if (!this.oauthStateMachine || !this.oauthFlowState) {
|
||||
throw new Error(
|
||||
"Not in guided OAuth flow. Call authenticateGuided() first.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.oauthStateMachine.executeStep(this.oauthState);
|
||||
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> {
|
||||
return this.createOAuthProvider("normal");
|
||||
async createOAuthProviderForTransport(): Promise<
|
||||
BaseOAuthClientProvider | EmaTransportOAuthProvider
|
||||
> {
|
||||
const provider = await this.createOAuthProvider("quick");
|
||||
if (this.isEnterpriseManaged()) {
|
||||
return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig());
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,14 @@ import {
|
|||
KeyringSecretStore,
|
||||
type SecretStore,
|
||||
} from "../../../auth/node/secret-store.js";
|
||||
import {
|
||||
deleteClientConfigStore,
|
||||
readClientConfigStore,
|
||||
writeClientConfigStore,
|
||||
} from "../../../client/node-persistence.js";
|
||||
import { formatClientConfigLoadError } from "../../../client/config-parse.js";
|
||||
import { envSecretField } from "../../../auth/secret-fields.js";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
/**
|
||||
* Shape of the initial config returned by GET /api/config (defaults for client).
|
||||
|
|
@ -808,6 +815,11 @@ export function createRemoteApp(
|
|||
const filePath = getStoreFilePath(storageDir, storeId);
|
||||
|
||||
try {
|
||||
if (storeId === "client") {
|
||||
const config = await readClientConfigStore(filePath, secretStore);
|
||||
return c.json(config);
|
||||
}
|
||||
|
||||
const raw = await readStoreFile(filePath);
|
||||
if (raw === null) {
|
||||
return c.json({}, 200);
|
||||
|
|
@ -836,10 +848,25 @@ export function createRemoteApp(
|
|||
const filePath = getStoreFilePath(storageDir, storeId);
|
||||
|
||||
try {
|
||||
if (storeId === "client") {
|
||||
await writeClientConfigStore(filePath, body, secretStore);
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
const jsonData = serializeStore(body);
|
||||
await writeStoreFile(filePath, jsonData);
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
// A malformed client.json body fails `parseClientConfig` with a ZodError
|
||||
// — that's a client error (bad request), not a server failure. Return 400
|
||||
// with the same human-readable formatting the load path uses, rather than
|
||||
// letting it fall through to the generic 500 below.
|
||||
if (error instanceof ZodError) {
|
||||
return c.json({ error: formatClientConfigLoadError(error) }, 400);
|
||||
}
|
||||
const keychainResp =
|
||||
storeId === "client" ? keychainErrorResponse(c, error) : undefined;
|
||||
if (keychainResp) return keychainResp;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return c.json({ error: `Failed to write store: ${msg}` }, 500);
|
||||
}
|
||||
|
|
@ -854,6 +881,11 @@ export function createRemoteApp(
|
|||
const filePath = getStoreFilePath(storageDir, storeId);
|
||||
|
||||
try {
|
||||
if (storeId === "client") {
|
||||
await deleteClientConfigStore(filePath, secretStore);
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
await deleteStoreFile(filePath);
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
|
|
@ -910,12 +942,23 @@ export function createRemoteApp(
|
|||
};
|
||||
const isOauthObject = (
|
||||
v: unknown,
|
||||
): v is { clientId?: string; clientSecret?: string; scopes?: string } => {
|
||||
): v is {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
} => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
|
||||
if (o[k] !== undefined && typeof o[k] !== "string") return false;
|
||||
}
|
||||
if (
|
||||
o.enterpriseManaged !== undefined &&
|
||||
typeof o.enterpriseManaged !== "boolean"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// A roots array: each entry must have a string `uri` and, when present, a
|
||||
|
|
@ -1021,7 +1064,7 @@ export function createRemoteApp(
|
|||
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "oauth" },
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes? }`.",
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, enterpriseManaged? }`.",
|
||||
);
|
||||
delete valObj.oauth;
|
||||
}
|
||||
|
|
@ -1260,6 +1303,15 @@ export function createRemoteApp(
|
|||
return { ok: false, error: `settings.${optional} must be a string` };
|
||||
}
|
||||
}
|
||||
if (
|
||||
obj.enterpriseManaged !== undefined &&
|
||||
typeof obj.enterpriseManaged !== "boolean"
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.enterpriseManaged must be a boolean",
|
||||
};
|
||||
}
|
||||
// roots is optional on the wire (older clients won't send it); when
|
||||
// present it must be an array of `{ uri, name? }`. Reuses the same
|
||||
// module-scope `isRootArray` guard the read path applies in
|
||||
|
|
@ -1319,6 +1371,9 @@ export function createRemoteApp(
|
|||
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
|
||||
value.oauthScopes = obj.oauthScopes;
|
||||
}
|
||||
if (obj.enterpriseManaged === true) {
|
||||
value.enterpriseManaged = true;
|
||||
}
|
||||
// Empty cwd coerces to absent on the value (matching the read side); the
|
||||
// write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted"
|
||||
// (preserve) via `cwdProvided` below rather than the value alone.
|
||||
|
|
@ -1524,6 +1579,27 @@ export function createRemoteApp(
|
|||
return obsolete;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge keychain secrets for a server-id rename. PUT payload values win
|
||||
* over the old id's keychain entries; keychain fills fields missing from
|
||||
* the payload (e.g. config-modal rename that does not re-send stripped
|
||||
* secrets). Filtered to the new on-disk entry's expected fields so env
|
||||
* keys removed during the rename are not carried over.
|
||||
*/
|
||||
const mergeRenameKeychainSecrets = (
|
||||
stripped: StoredMCPServer,
|
||||
keychainSecrets: Record<string, string>,
|
||||
nextSecrets: Record<string, string>,
|
||||
): Record<string, string> => {
|
||||
const allowedFields = new Set(expectedSecretFields(stripped));
|
||||
const merged = { ...keychainSecrets, ...nextSecrets };
|
||||
const out: Record<string, string> = {};
|
||||
for (const [field, value] of Object.entries(merged)) {
|
||||
if (allowedFields.has(field)) out[field] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Parallel for symmetry with `readKeychainEntriesFor` /
|
||||
// `writeKeychainEntriesFor`: distinct (id, field) deletes have no
|
||||
// ordering requirement, and `secretStore.delete` is already a silent
|
||||
|
|
@ -1977,7 +2053,17 @@ export function createRemoteApp(
|
|||
// write leaves orphan keychain entries — recoverable on the
|
||||
// next reconcile or `deleteAllForServer` sweep.
|
||||
if (newId !== originalId) {
|
||||
await writeKeychainEntriesFor(newId, secrets);
|
||||
const previousFields = expectedSecretFields(existing);
|
||||
const keychainSecrets = await readKeychainEntriesFor(
|
||||
originalId,
|
||||
previousFields,
|
||||
);
|
||||
const secretsToWrite = mergeRenameKeychainSecrets(
|
||||
stripped,
|
||||
keychainSecrets,
|
||||
secrets,
|
||||
);
|
||||
await writeKeychainEntriesFor(newId, secretsToWrite);
|
||||
await writeMcpAndTrackMtime(serializeStore(next));
|
||||
await secretStore.deleteAllForServer(originalId);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type {
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type { RemoteConnectRequest } from "../types.js";
|
||||
|
||||
const REMOTE_OAUTH_STUB_METADATA: OAuthClientMetadata = {
|
||||
redirect_uris: [],
|
||||
scope: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple OAuth client provider that just returns tokens.
|
||||
* Used by the remote server to inject Bearer tokens into transport requests.
|
||||
* The other OAuthClientProvider methods are required by the interface but never
|
||||
* exercised in the remote-server path (the SDK only calls `tokens()` for Bearer
|
||||
* injection); they are kept as no-op stubs.
|
||||
*
|
||||
* The SDK may invoke {@link auth} on 401; stubs must satisfy the full provider
|
||||
* surface so that path fails with a clear error instead of throwing on
|
||||
* undefined `clientMetadata.scope`.
|
||||
*/
|
||||
export function createTokenAuthProvider(
|
||||
tokens: RemoteConnectRequest["oauthTokens"],
|
||||
|
|
@ -15,6 +24,12 @@ export function createTokenAuthProvider(
|
|||
if (!tokens) return undefined;
|
||||
|
||||
return {
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return REMOTE_OAUTH_STUB_METADATA;
|
||||
},
|
||||
get redirectUrl(): string {
|
||||
return "";
|
||||
},
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return tokens as OAuthTokens;
|
||||
},
|
||||
|
|
@ -33,8 +48,10 @@ export function createTokenAuthProvider(
|
|||
clear() {
|
||||
// No-op
|
||||
},
|
||||
redirectToAuthorization() {
|
||||
// No-op
|
||||
async redirectToAuthorization() {
|
||||
throw new Error(
|
||||
"OAuth re-authorization must be performed in the Inspector web client (remote server cannot complete OAuth flows)",
|
||||
);
|
||||
},
|
||||
state() {
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -199,6 +199,9 @@ export function storedFieldsToInspectorSettings(
|
|||
if (stored.oauth?.clientSecret)
|
||||
settings.oauthClientSecret = stored.oauth.clientSecret;
|
||||
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
|
||||
if (stored.oauth?.enterpriseManaged === true) {
|
||||
settings.enterpriseManaged = true;
|
||||
}
|
||||
// Mirror the stdio working directory for the form. Like the OAuth fields, an
|
||||
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
|
||||
if (stored.cwd) settings.cwd = stored.cwd;
|
||||
|
|
@ -268,11 +271,15 @@ export function inspectorSettingsToStoredFields(
|
|||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
} = {};
|
||||
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
|
||||
if (settings.oauthClientSecret)
|
||||
oauthFields.clientSecret = settings.oauthClientSecret;
|
||||
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
|
||||
if (settings.enterpriseManaged === true) {
|
||||
oauthFields.enterpriseManaged = true;
|
||||
}
|
||||
if (Object.keys(oauthFields).length > 0) {
|
||||
out.oauth = oauthFields;
|
||||
}
|
||||
|
|
@ -467,11 +474,18 @@ export function extractSecretsFromStored(
|
|||
|
||||
if (stored.oauth?.clientSecret) {
|
||||
secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET] = stored.oauth.clientSecret;
|
||||
const restOauth: { clientId?: string; scopes?: string } = {};
|
||||
const restOauth: {
|
||||
clientId?: string;
|
||||
scopes?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
} = {};
|
||||
if (stored.oauth.clientId !== undefined)
|
||||
restOauth.clientId = stored.oauth.clientId;
|
||||
if (stored.oauth.scopes !== undefined)
|
||||
restOauth.scopes = stored.oauth.scopes;
|
||||
if (stored.oauth.enterpriseManaged === true) {
|
||||
restOauth.enterpriseManaged = true;
|
||||
}
|
||||
if (Object.keys(restOauth).length > 0) {
|
||||
stripped.oauth = restOauth;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.
|
|||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { InspectorLogger } from "../logging/logger.js";
|
||||
import type { JsonValue } from "../json/jsonUtils.js";
|
||||
import type {
|
||||
ClientConfig,
|
||||
EnterpriseManagedAuthIdpConfig,
|
||||
} from "../client/types.js";
|
||||
import type {
|
||||
OAuthNavigation,
|
||||
RedirectUrlProvider,
|
||||
|
|
@ -128,6 +132,8 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
/** When true, connect via enterprise IdP (EMA) instead of standard resource OAuth. */
|
||||
enterpriseManaged?: boolean;
|
||||
};
|
||||
/**
|
||||
* Filesystem/URI roots advertised to the server via the `roots` client
|
||||
|
|
@ -397,6 +403,7 @@ export interface OAuthSettings {
|
|||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
enterpriseManaged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -445,6 +452,12 @@ export interface InspectorServerSettings {
|
|||
oauthClientId?: string;
|
||||
oauthClientSecret?: string;
|
||||
oauthScopes?: string;
|
||||
/**
|
||||
* When true, connect via the configured enterprise IdP (EMA) instead of
|
||||
* interactive OAuth to the MCP authorization server. Per-server OAuth
|
||||
* fields below are resource AS credentials. (#1509)
|
||||
*/
|
||||
enterpriseManaged?: boolean;
|
||||
/**
|
||||
* When true, lists auto-refresh on `list_changed` notifications; when
|
||||
* false (default), the notification only lights the list-changed indicator
|
||||
|
|
@ -715,8 +728,24 @@ export interface InspectorClientOptions {
|
|||
clientSecret?: string;
|
||||
clientMetadataUrl?: string;
|
||||
scope?: string;
|
||||
/** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */
|
||||
enterpriseManaged?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Global enterprise IdP credentials (from client.json). Used for EMA legs 1–2
|
||||
* when {@link oauth.enterpriseManaged} is true on the server.
|
||||
*/
|
||||
enterpriseManagedAuth?: {
|
||||
idp: EnterpriseManagedAuthIdpConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Full install-level EMA config from client.json (including when disabled).
|
||||
* Used to produce friendly errors when a server expects EMA but IdP is inactive.
|
||||
*/
|
||||
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
|
||||
|
||||
/**
|
||||
* Optional session ID. If not provided, will be extracted from OAuth state
|
||||
* when OAuth flow starts. Passed in saveSession event for FetchRequestLogState.
|
||||
|
|
|
|||
Loading…
Reference in a new issue