All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
23 changed files with 11437 additions and 1212 deletions
331
core/auth/challenge.ts
Normal file
331
core/auth/challenge.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { isUnauthorizedError } from "./utils.js";
|
||||
|
||||
/** Why authorization failed for this MCP interaction. */
|
||||
export type AuthChallengeReason =
|
||||
| "unauthorized"
|
||||
| "token_expired"
|
||||
| "insufficient_scope"
|
||||
| "invalid_token";
|
||||
|
||||
/** Normalized challenge for handleAuthChallenge(). */
|
||||
export interface AuthChallenge {
|
||||
reason: AuthChallengeReason;
|
||||
|
||||
/** Scopes from the current challenge (step-up). */
|
||||
requiredScopes?: string[];
|
||||
|
||||
/**
|
||||
* For step-up (SEP-2350): union of previously requested scopes and requiredScopes.
|
||||
* Set by handleAuthChallenge before re-authorization; not sent on the wire.
|
||||
*/
|
||||
authorizationScopes?: string[];
|
||||
|
||||
/** Resource indicator / MCP resource URL when known (EMA RFC 8707). */
|
||||
resource?: string;
|
||||
|
||||
/** Resource authorization server audience when known. */
|
||||
audience?: string;
|
||||
|
||||
/** Optional human-readable detail from server or SDK (for UI, not parsing). */
|
||||
message?: string;
|
||||
|
||||
/** Optional UX hints when known (not used for ambient RPC replay). */
|
||||
context?: {
|
||||
method?: string;
|
||||
toolName?: string;
|
||||
};
|
||||
|
||||
/** Opaque raw hints for logging and forward-compatible parsers. */
|
||||
raw?: {
|
||||
httpStatus?: number;
|
||||
wwwAuthenticate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AuthChallengeOutcome =
|
||||
| { kind: "satisfied" }
|
||||
| { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge }
|
||||
| { kind: "step_up_confirm"; challenge: AuthChallenge }
|
||||
| { kind: "failed"; error: Error };
|
||||
|
||||
/** Placeholder URL when EMA step-up awaits in-app confirmation (no redirect yet). */
|
||||
export const EMA_STEP_UP_PENDING_URL = new URL("mcp-inspector:ema-step-up");
|
||||
|
||||
export interface HandleAuthChallengeOptions {
|
||||
/** User confirmed step-up in Inspector UI — run silent EMA re-mint / IdP redirect. */
|
||||
confirmedStepUp?: boolean;
|
||||
}
|
||||
|
||||
export interface ParseAuthChallengeContext {
|
||||
method?: string;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export class AuthChallengeError extends Error {
|
||||
readonly authChallenge: AuthChallenge;
|
||||
readonly status: number;
|
||||
|
||||
constructor(
|
||||
authChallenge: AuthChallenge,
|
||||
status: number,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `Auth challenge: ${authChallenge.reason}`);
|
||||
this.name = "AuthChallengeError";
|
||||
this.authChallenge = authChallenge;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when interactive auth recovery was started and the caller should wait for callback. */
|
||||
export class AuthRecoveryRequiredError extends Error {
|
||||
readonly authorizationUrl: URL;
|
||||
readonly authChallenge: AuthChallenge;
|
||||
/** EMA insufficient_scope awaiting user confirmation in Inspector (no redirect yet). */
|
||||
readonly emaStepUpConfirm?: boolean;
|
||||
|
||||
constructor(
|
||||
authorizationUrl: URL,
|
||||
authChallenge: AuthChallenge,
|
||||
options?: { emaStepUpConfirm?: boolean },
|
||||
) {
|
||||
super("Interactive auth recovery required");
|
||||
this.name = "AuthRecoveryRequiredError";
|
||||
this.authorizationUrl = authorizationUrl;
|
||||
this.authChallenge = authChallenge;
|
||||
this.emaStepUpConfirm = options?.emaStepUpConfirm;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect-time failures the app can recover via OAuth redirect. These are not
|
||||
* terminal connection errors — the UI should stay on "connecting" until the
|
||||
* redirect or an explicit recovery failure.
|
||||
*/
|
||||
export function isConnectAuthRecoveryError(err: unknown): boolean {
|
||||
if (err instanceof AuthRecoveryRequiredError) return true;
|
||||
return isUnauthorizedError(err);
|
||||
}
|
||||
|
||||
export interface WwwAuthenticateBearerParams {
|
||||
error?: string;
|
||||
scope?: string;
|
||||
resourceMetadata?: string;
|
||||
errorDescription?: string;
|
||||
}
|
||||
|
||||
/** Parse the first `WWW-Authenticate: Bearer …` challenge (RFC 6750). */
|
||||
export function parseWwwAuthenticateBearer(
|
||||
header: string,
|
||||
): WwwAuthenticateBearerParams {
|
||||
const match = header.match(/Bearer\s+(.+)/i);
|
||||
if (!match) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
const paramRegex = /([\w-]+)(?:="([^"]*)"|=([^\s,]+))?/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = paramRegex.exec(match[1])) !== null) {
|
||||
const value = m[2] ?? m[3];
|
||||
if (value !== undefined) {
|
||||
params[m[1].toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: params.error,
|
||||
scope: params.scope,
|
||||
resourceMetadata: params.resource_metadata,
|
||||
errorDescription: params.error_description,
|
||||
};
|
||||
}
|
||||
|
||||
/** Split an OAuth scope string into individual scopes (space-separated). */
|
||||
export function parseScopeString(scope: string | undefined): string[] {
|
||||
if (!scope?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return scope.trim().split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEP-2350: union of previously requested scopes and scopes from the current challenge.
|
||||
* Preserves order: previous scopes first, then any new required scopes.
|
||||
*/
|
||||
export function unionAuthorizationScopes(
|
||||
previousScope: string | undefined,
|
||||
requiredScopes: string[],
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const scope of [
|
||||
...parseScopeString(previousScope),
|
||||
...requiredScopes.filter(Boolean),
|
||||
]) {
|
||||
if (!seen.has(scope)) {
|
||||
seen.add(scope);
|
||||
result.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function reasonFromHttpResponse(
|
||||
status: number,
|
||||
bearer: WwwAuthenticateBearerParams,
|
||||
): AuthChallengeReason {
|
||||
if (status === 403) {
|
||||
if (bearer.error === "insufficient_scope") {
|
||||
return "insufficient_scope";
|
||||
}
|
||||
return "unauthorized";
|
||||
}
|
||||
|
||||
if (bearer.error === "insufficient_scope") {
|
||||
return "insufficient_scope";
|
||||
}
|
||||
|
||||
if (bearer.error === "invalid_token") {
|
||||
return "invalid_token";
|
||||
}
|
||||
|
||||
// Bare 401 without a Bearer error code — treat as expired token for silent
|
||||
// refresh / reauth UX (connect-time 401 uses isUnauthorizedError separately).
|
||||
if (status === 401) {
|
||||
return "token_expired";
|
||||
}
|
||||
|
||||
return "unauthorized";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AuthChallenge from an MCP HTTP response (401 / 403).
|
||||
* Returns undefined when the response is not an auth challenge.
|
||||
*/
|
||||
export function parseAuthChallengeFromResponse(
|
||||
response: Response,
|
||||
context?: ParseAuthChallengeContext,
|
||||
): AuthChallenge | undefined {
|
||||
const status = response.status;
|
||||
if (status !== 401 && status !== 403) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const wwwAuthenticate = response.headers.get("WWW-Authenticate") ?? undefined;
|
||||
const bearer = wwwAuthenticate
|
||||
? parseWwwAuthenticateBearer(wwwAuthenticate)
|
||||
: {};
|
||||
const requiredScopes = parseScopeString(bearer.scope);
|
||||
|
||||
return {
|
||||
reason: reasonFromHttpResponse(status, bearer),
|
||||
...(requiredScopes.length > 0 ? { requiredScopes } : {}),
|
||||
...(bearer.errorDescription ? { message: bearer.errorDescription } : {}),
|
||||
...(context ? { context } : {}),
|
||||
raw: {
|
||||
httpStatus: status,
|
||||
...(wwwAuthenticate ? { wwwAuthenticate } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort challenge extraction from SDK / transport errors. */
|
||||
export function parseAuthChallengeFromError(
|
||||
err: unknown,
|
||||
context?: ParseAuthChallengeContext,
|
||||
): AuthChallenge | undefined {
|
||||
if (err instanceof AuthChallengeError) {
|
||||
return err.authChallenge;
|
||||
}
|
||||
|
||||
if (typeof err !== "object" || err === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authChallenge = (err as { authChallenge?: AuthChallenge })
|
||||
.authChallenge;
|
||||
if (authChallenge?.reason) {
|
||||
return {
|
||||
...authChallenge,
|
||||
...(context
|
||||
? {
|
||||
context: {
|
||||
...authChallenge.context,
|
||||
...context,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const status =
|
||||
(err as { status?: number }).status ??
|
||||
(err as { code?: number }).code;
|
||||
if (status !== 401 && status !== 403) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const wwwAuthenticate =
|
||||
authChallenge?.raw?.wwwAuthenticate ??
|
||||
(err as { wwwAuthenticate?: string }).wwwAuthenticate ??
|
||||
(err as { headers?: { get?: (name: string) => string | null } }).headers
|
||||
?.get?.("WWW-Authenticate") ??
|
||||
undefined;
|
||||
|
||||
if (!wwwAuthenticate?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bearer = parseWwwAuthenticateBearer(wwwAuthenticate);
|
||||
const requiredScopes = parseScopeString(bearer.scope);
|
||||
|
||||
return {
|
||||
reason: reasonFromHttpResponse(status, bearer),
|
||||
...(requiredScopes.length > 0 ? { requiredScopes } : {}),
|
||||
...(context ? { context } : {}),
|
||||
raw: {
|
||||
httpStatus: status,
|
||||
wwwAuthenticate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True for mid-session auth failures (HTTP 401 or 403 on MCP traffic).
|
||||
* Connect-time 401 detection remains {@link isUnauthorizedError} in utils.ts.
|
||||
*
|
||||
* Bare HTTP status codes alone are not treated as auth challenges — require
|
||||
* {@link AuthChallengeError}, an embedded `authChallenge`, or `WWW-Authenticate`.
|
||||
*/
|
||||
export function isAuthChallengeError(err: unknown): boolean {
|
||||
if (err instanceof AuthChallengeError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof err !== "object" || err === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authChallenge = (err as { authChallenge?: AuthChallenge }).authChallenge;
|
||||
if (authChallenge?.reason) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status =
|
||||
(err as { status?: number }).status ?? (err as { code?: number }).code;
|
||||
if (status !== 401 && status !== 403) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wwwAuthenticate =
|
||||
authChallenge?.raw?.wwwAuthenticate ??
|
||||
(err as { wwwAuthenticate?: string }).wwwAuthenticate ??
|
||||
(err as { headers?: { get?: (name: string) => string | null } }).headers
|
||||
?.get?.("WWW-Authenticate") ??
|
||||
undefined;
|
||||
|
||||
return wwwAuthenticate !== undefined && wwwAuthenticate.length > 0;
|
||||
}
|
||||
|
|
@ -19,7 +19,10 @@ export interface BuildOAuthConnectionStateParams {
|
|||
flowState?: OAuthFlowState;
|
||||
}
|
||||
|
||||
function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean {
|
||||
/** True when persisted tokens include a non-expired access token (JWT exp when parseable). */
|
||||
export function isAccessTokenUsable(
|
||||
tokens: OAuthTokens | undefined,
|
||||
): boolean {
|
||||
if (!tokens?.access_token) return false;
|
||||
return !isJwtExpired(tokens.access_token);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ export type { BuildOAuthConnectionStateParams } from "./connection-state.js";
|
|||
|
||||
export { ensureCimdClientRegistration } from "./cimd.js";
|
||||
|
||||
export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js";
|
||||
export {
|
||||
computeScopeUnion,
|
||||
isStrictScopeSuperset,
|
||||
} from "./scopes.js";
|
||||
|
||||
// Storage
|
||||
export type { OAuthStorage, IdpSessionState, SaveClientInformationOptions } from "./storage.js";
|
||||
export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js";
|
||||
|
|
@ -49,9 +55,50 @@ export {
|
|||
generateOAuthState,
|
||||
parseOAuthState,
|
||||
generateOAuthErrorDescription,
|
||||
formatOAuthFailureDetail,
|
||||
isUnauthorizedError,
|
||||
} from "./utils.js";
|
||||
|
||||
export type {
|
||||
AuthChallenge,
|
||||
AuthChallengeReason,
|
||||
AuthChallengeOutcome,
|
||||
HandleAuthChallengeOptions,
|
||||
ParseAuthChallengeContext,
|
||||
WwwAuthenticateBearerParams,
|
||||
} from "./challenge.js";
|
||||
export {
|
||||
AuthChallengeError,
|
||||
AuthRecoveryRequiredError,
|
||||
parseAuthChallengeFromError,
|
||||
parseAuthChallengeFromResponse,
|
||||
parseScopeString,
|
||||
parseWwwAuthenticateBearer,
|
||||
unionAuthorizationScopes,
|
||||
isAuthChallengeError,
|
||||
isConnectAuthRecoveryError,
|
||||
EMA_STEP_UP_PENDING_URL,
|
||||
} from "./challenge.js";
|
||||
|
||||
export {
|
||||
isStandardOAuthStepUp,
|
||||
isEmaStepUp,
|
||||
isStepUpConfirmation,
|
||||
stepUpConfirmMessage,
|
||||
stepUpFollowUpMessage,
|
||||
stepUpModalTitle,
|
||||
stepUpAuthorizeActionLabel,
|
||||
emaStepUpInProgressMessage,
|
||||
emaStepUpSuccessMessage,
|
||||
emaStepUpFailureMessage,
|
||||
stepUpAdditionalScopes,
|
||||
stepUpInsufficientScopeMessage,
|
||||
oauthPreRedirectToastCopy,
|
||||
isReAuthBannerReason,
|
||||
reAuthBannerMessage,
|
||||
type OAuthInteractiveAuthKind,
|
||||
} from "./oauthUx.js";
|
||||
|
||||
// Discovery
|
||||
export { discoverScopes } from "./discovery.js";
|
||||
|
||||
|
|
|
|||
156
core/auth/mcpAuth.ts
Normal file
156
core/auth/mcpAuth.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* v2-shaped OAuth orchestrator for standard MCP resource authorization.
|
||||
*
|
||||
* **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).
|
||||
*/
|
||||
|
||||
import {
|
||||
auth as sdkAuth,
|
||||
discoverOAuthServerInfo,
|
||||
isHttpsUrl,
|
||||
registerClient,
|
||||
selectResourceURL,
|
||||
startAuthorization,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
||||
|
||||
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.
|
||||
*/
|
||||
export interface McpAuthOptions {
|
||||
serverUrl: string | URL;
|
||||
authorizationCode?: string;
|
||||
/** RFC 9207 callback `iss` — forwarded on v2 upgrade; ignored by v1 SDK `auth()`. */
|
||||
iss?: string;
|
||||
scope?: string;
|
||||
resourceMetadataUrl?: URL;
|
||||
fetchFn?: FetchLike;
|
||||
/** v2 SEP-2468 — ignored until SDK v2 upgrade. */
|
||||
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).
|
||||
*/
|
||||
forceReauthorization?: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return sdkAuth(provider, {
|
||||
serverUrl: options.serverUrl,
|
||||
authorizationCode: options.authorizationCode,
|
||||
scope: options.scope,
|
||||
resourceMetadataUrl: options.resourceMetadataUrl,
|
||||
fetchFn: options.fetchFn,
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
|
@ -23,3 +23,12 @@ export {
|
|||
parseRunnerOAuthCallbackUrl,
|
||||
} from "./runner-oauth-callback.js";
|
||||
export type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js";
|
||||
export {
|
||||
runRunnerInteractiveOAuth,
|
||||
} from "./runner-interactive-oauth.js";
|
||||
export type {
|
||||
RunRunnerInteractiveOAuthOptions,
|
||||
RunnerInteractiveOAuthClient,
|
||||
RunnerInteractiveOAuthRedirectProvider,
|
||||
RunnerInteractiveOAuthResult,
|
||||
} from "./runner-interactive-oauth.js";
|
||||
|
|
|
|||
136
core/auth/node/runner-interactive-oauth.ts
Normal file
136
core/auth/node/runner-interactive-oauth.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import type { AuthChallenge } from "../challenge.js";
|
||||
import {
|
||||
createOAuthCallbackServer,
|
||||
type OAuthCallbackServer,
|
||||
} from "./oauth-callback-server.js";
|
||||
import type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js";
|
||||
|
||||
/** Minimal InspectorClient surface for runner interactive OAuth. */
|
||||
export interface RunnerInteractiveOAuthClient {
|
||||
authenticate(): Promise<URL | undefined>;
|
||||
beginInteractiveAuthorization(authorizationUrl: URL): Promise<void>;
|
||||
completeOAuthFlow(authorizationCode: string): Promise<void>;
|
||||
checkAuthChallengeSatisfied(challenge: AuthChallenge): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RunnerInteractiveOAuthRedirectProvider {
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
export type RunnerInteractiveOAuthResult =
|
||||
| { kind: "success" }
|
||||
| { kind: "already_authorized" }
|
||||
| { kind: "insufficient_scope"; challenge: AuthChallenge };
|
||||
|
||||
/** Default wait for loopback OAuth callback (15 minutes). */
|
||||
export const DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface RunRunnerInteractiveOAuthOptions {
|
||||
client: RunnerInteractiveOAuthClient;
|
||||
redirectUrlProvider: RunnerInteractiveOAuthRedirectProvider;
|
||||
callbackListen: RunnerOAuthCallbackConfig;
|
||||
/** When set, use deferred interactive authorization (mid-session step-up / re-login). */
|
||||
authorizationUrl?: URL;
|
||||
/** When set, verify scopes after a successful token exchange (SEP-2350 step-up). */
|
||||
authChallenge?: AuthChallenge;
|
||||
createCallbackServer?: () => OAuthCallbackServer;
|
||||
/** Invoked after the listener binds; hosts may keep a ref for unmount cleanup. */
|
||||
onCallbackServer?: (server: OAuthCallbackServer) => void;
|
||||
/** Max wait for browser callback; defaults to {@link DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS}. */
|
||||
callbackTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run interactive OAuth for Node runners (TUI / CLI): loopback callback server,
|
||||
* browser redirect, authorization-code exchange via {@link completeOAuthFlow}.
|
||||
*/
|
||||
export async function runRunnerInteractiveOAuth(
|
||||
options: RunRunnerInteractiveOAuthOptions,
|
||||
): Promise<RunnerInteractiveOAuthResult> {
|
||||
const createServer =
|
||||
options.createCallbackServer ?? createOAuthCallbackServer;
|
||||
const server = createServer();
|
||||
|
||||
let flowResolve!: () => void;
|
||||
let flowReject!: (err: Error) => void;
|
||||
const flowDone = new Promise<void>((resolve, reject) => {
|
||||
flowResolve = resolve;
|
||||
flowReject = reject;
|
||||
});
|
||||
|
||||
try {
|
||||
const { redirectUrl } = await server.start({
|
||||
hostname: options.callbackListen.hostname,
|
||||
port: options.callbackListen.port,
|
||||
path: options.callbackListen.pathname,
|
||||
onCallback: async (params) => {
|
||||
try {
|
||||
await options.client.completeOAuthFlow(params.code);
|
||||
flowResolve();
|
||||
} catch (err) {
|
||||
flowReject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
onError: (params) => {
|
||||
flowReject(
|
||||
new Error(
|
||||
params.error_description ?? params.error ?? "OAuth error",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
options.onCallbackServer?.(server);
|
||||
options.redirectUrlProvider.redirectUrl = redirectUrl;
|
||||
|
||||
const timeoutMs =
|
||||
options.callbackTimeoutMs ?? DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const waitForCallback = Promise.race([
|
||||
flowDone.finally(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`OAuth callback timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
if (options.authorizationUrl) {
|
||||
await options.client.beginInteractiveAuthorization(
|
||||
options.authorizationUrl,
|
||||
);
|
||||
await waitForCallback;
|
||||
} else {
|
||||
const authUrl = await options.client.authenticate();
|
||||
if (authUrl !== undefined) {
|
||||
await waitForCallback;
|
||||
} else {
|
||||
return { kind: "already_authorized" };
|
||||
}
|
||||
}
|
||||
|
||||
if (options.authChallenge) {
|
||||
const satisfied = await options.client.checkAuthChallengeSatisfied(
|
||||
options.authChallenge,
|
||||
);
|
||||
if (!satisfied) {
|
||||
return {
|
||||
kind: "insufficient_scope",
|
||||
challenge: options.authChallenge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "success" };
|
||||
} finally {
|
||||
await server.stop().catch(() => {});
|
||||
}
|
||||
}
|
||||
236
core/auth/oauthUx.ts
Normal file
236
core/auth/oauthUx.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import type { AuthChallenge } from "./challenge.js";
|
||||
|
||||
export type OAuthInteractiveAuthKind = "step_up" | "reauth";
|
||||
|
||||
/** Origin of an interactive OAuth recovery flow (command, ambient, connect, etc.). */
|
||||
export type OAuthRecoverySource =
|
||||
| "tool"
|
||||
| "prompt"
|
||||
| "resource"
|
||||
| "ambient"
|
||||
| "app";
|
||||
|
||||
export function isActionTriggeredOAuthRecovery(
|
||||
source: OAuthRecoverySource | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
source === "tool" ||
|
||||
source === "prompt" ||
|
||||
source === "resource" ||
|
||||
source === "app"
|
||||
);
|
||||
}
|
||||
|
||||
export function oauthResumeSuccessMessage(
|
||||
authKind: OAuthInteractiveAuthKind,
|
||||
options?: { recoverySource?: OAuthRecoverySource },
|
||||
): string {
|
||||
const retry = isActionTriggeredOAuthRecovery(options?.recoverySource);
|
||||
if (authKind === "step_up") {
|
||||
return retry
|
||||
? "Step-up authorization succeeded. Retry your action."
|
||||
: "Step-up authorization succeeded.";
|
||||
}
|
||||
return retry
|
||||
? "Authentication succeeded. Retry your action."
|
||||
: "Authentication succeeded.";
|
||||
}
|
||||
|
||||
export function authRecoveryRestoredMessage(options?: {
|
||||
recoverySource?: OAuthRecoverySource;
|
||||
}): string {
|
||||
const retry = isActionTriggeredOAuthRecovery(options?.recoverySource);
|
||||
return retry
|
||||
? "Session credentials were updated. Retry your action."
|
||||
: "Session credentials were updated.";
|
||||
}
|
||||
|
||||
export function oauthResumeAbandonedMessage(
|
||||
authKind: OAuthInteractiveAuthKind,
|
||||
options?: { recoverySource?: OAuthRecoverySource },
|
||||
): string {
|
||||
if (authKind === "reauth") {
|
||||
return "Sign-in was not completed. Re-authenticate to restore access.";
|
||||
}
|
||||
return isActionTriggeredOAuthRecovery(options?.recoverySource)
|
||||
? "Step-up authorization was not completed. Retry your action."
|
||||
: "Step-up authorization was not completed.";
|
||||
}
|
||||
|
||||
/** Standard-OAuth step-up (not EMA silent re-mint). */
|
||||
export function isStandardOAuthStepUp(
|
||||
challenge: AuthChallenge,
|
||||
options?: { enterpriseManaged?: boolean },
|
||||
): boolean {
|
||||
return (
|
||||
challenge.reason === "insufficient_scope" && !options?.enterpriseManaged
|
||||
);
|
||||
}
|
||||
|
||||
/** EMA step-up (`insufficient_scope` on an enterprise-managed server). */
|
||||
export function isEmaStepUp(
|
||||
challenge: AuthChallenge,
|
||||
options?: { enterpriseManaged?: boolean },
|
||||
): boolean {
|
||||
return (
|
||||
challenge.reason === "insufficient_scope" &&
|
||||
options?.enterpriseManaged === true
|
||||
);
|
||||
}
|
||||
|
||||
/** Any step-up that should show the Inspector confirmation modal before proceeding. */
|
||||
export function isStepUpConfirmation(
|
||||
challenge: AuthChallenge,
|
||||
options?: { enterpriseManaged?: boolean },
|
||||
): boolean {
|
||||
return (
|
||||
isStandardOAuthStepUp(challenge, options) ||
|
||||
isEmaStepUp(challenge, options)
|
||||
);
|
||||
}
|
||||
|
||||
export function stepUpConfirmMessage(
|
||||
challenge: AuthChallenge,
|
||||
options?: { enterpriseManaged?: boolean },
|
||||
): string {
|
||||
const toolName = challenge.context?.toolName?.trim();
|
||||
if (toolName) {
|
||||
return options?.enterpriseManaged
|
||||
? `Tool "${toolName}" needs additional permissions from your organization before it can continue.`
|
||||
: `Tool "${toolName}" needs additional OAuth scopes before it can continue.`;
|
||||
}
|
||||
const additional = challenge.requiredScopes?.filter(Boolean);
|
||||
if (additional?.length) {
|
||||
const label = additional.length === 1 ? "scope" : "scopes";
|
||||
return options?.enterpriseManaged
|
||||
? `This operation needs additional organization ${label}: ${additional.join(", ")}.`
|
||||
: `This operation needs additional ${label}: ${additional.join(", ")}.`;
|
||||
}
|
||||
return options?.enterpriseManaged
|
||||
? "This operation needs additional permissions from your organization before it can continue."
|
||||
: "This operation needs additional OAuth scopes before it can continue.";
|
||||
}
|
||||
|
||||
/** Body copy below the step-up summary (what happens on Authorize). */
|
||||
export function stepUpFollowUpMessage(options?: {
|
||||
enterpriseManaged?: boolean;
|
||||
}): string {
|
||||
return options?.enterpriseManaged
|
||||
? "Inspector will request the additional permissions from your enterprise identity provider. You may be asked to sign in if your organization session expired."
|
||||
: "You will be redirected to authorize, then returned to the inspector.";
|
||||
}
|
||||
|
||||
/** Step-up confirm action label (e.g. TUI menu item). EMA re-mints in-process when possible. */
|
||||
export function stepUpAuthorizeActionLabel(options?: {
|
||||
enterpriseManaged?: boolean;
|
||||
}): string {
|
||||
return options?.enterpriseManaged
|
||||
? "Authorize"
|
||||
: "Authorize (opens browser)";
|
||||
}
|
||||
|
||||
export function stepUpModalTitle(options?: {
|
||||
enterpriseManaged?: boolean;
|
||||
}): string {
|
||||
return options?.enterpriseManaged
|
||||
? "Additional organization permissions required"
|
||||
: "Additional permissions required";
|
||||
}
|
||||
|
||||
/** Toast while EMA step-up is in progress after user confirms. */
|
||||
export function emaStepUpInProgressMessage(): string {
|
||||
return "Requesting additional permissions from your organization…";
|
||||
}
|
||||
|
||||
/** Toast when EMA step-up completes successfully. */
|
||||
export function emaStepUpSuccessMessage(options?: {
|
||||
recoverySource?: OAuthRecoverySource;
|
||||
}): string {
|
||||
const retry = isActionTriggeredOAuthRecovery(options?.recoverySource);
|
||||
return retry
|
||||
? "Organization permissions were updated. Retry your action."
|
||||
: "Organization permissions were updated.";
|
||||
}
|
||||
|
||||
/** Toast when EMA step-up fails after user confirmation. */
|
||||
export function emaStepUpFailureMessage(detail?: string): string {
|
||||
return detail?.trim()
|
||||
? detail
|
||||
: "Could not obtain the additional permissions from your organization.";
|
||||
}
|
||||
|
||||
/** Scopes the current operation still lacks (from the resource-server challenge). */
|
||||
export function stepUpAdditionalScopes(challenge: AuthChallenge): string[] {
|
||||
return challenge.requiredScopes?.filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
export function stepUpInsufficientScopeMessage(
|
||||
challenge: AuthChallenge,
|
||||
): string {
|
||||
const toolName = challenge.context?.toolName?.trim();
|
||||
if (toolName) {
|
||||
return `Authorization completed, but required permissions for tool "${toolName}" were not granted. Grant the requested scopes on the authorization server, then retry.`;
|
||||
}
|
||||
const missing =
|
||||
challenge.authorizationScopes?.filter(Boolean) ??
|
||||
challenge.requiredScopes?.filter(Boolean);
|
||||
if (missing?.length) {
|
||||
return `Authorization completed, but required scopes were not granted (${missing.join(", ")}). Grant the requested permissions on the authorization server, then retry your action.`;
|
||||
}
|
||||
return "Authorization completed, but the required permissions were not granted. Grant the requested scopes on the authorization server, then retry your action.";
|
||||
}
|
||||
|
||||
export type OAuthPreRedirectContext = "connect" | "session_recovery";
|
||||
|
||||
/** Pre-redirect toast copy for interactive OAuth. */
|
||||
export function oauthPreRedirectToastCopy(
|
||||
authKind: OAuthInteractiveAuthKind,
|
||||
options: {
|
||||
serverName?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
/** Fresh connect handshake — no existing session to recover. */
|
||||
context?: OAuthPreRedirectContext;
|
||||
},
|
||||
): { title: string; message: string } | undefined {
|
||||
if (options.context === "connect") {
|
||||
return undefined;
|
||||
}
|
||||
const name = options.serverName;
|
||||
if (authKind === "step_up") {
|
||||
return {
|
||||
title: name ? `Step-up authorization for "${name}"` : "Step-up authorization",
|
||||
message: "Redirecting to authorize additional permissions…",
|
||||
};
|
||||
}
|
||||
if (options.enterpriseManaged) {
|
||||
return {
|
||||
title: name ? `Re-authenticating "${name}"` : "Re-authenticating",
|
||||
message: "Re-authenticating…",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: name ? `Session expired for "${name}"` : "Session expired",
|
||||
message: "Session expired, re-authenticating…",
|
||||
};
|
||||
}
|
||||
|
||||
/** Challenge reasons that warrant a persistent re-auth banner (degraded session). */
|
||||
export function isReAuthBannerReason(
|
||||
reason: AuthChallenge["reason"] | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
reason === "token_expired" ||
|
||||
reason === "unauthorized" ||
|
||||
reason === "invalid_token"
|
||||
);
|
||||
}
|
||||
|
||||
export function reAuthBannerMessage(options: {
|
||||
serverName?: string;
|
||||
detail?: string;
|
||||
}): string {
|
||||
const prefix = options.serverName
|
||||
? `Authentication for "${options.serverName}" needs attention.`
|
||||
: "Authentication needs attention.";
|
||||
return options.detail ? `${prefix} ${options.detail}` : prefix;
|
||||
}
|
||||
|
|
@ -102,6 +102,7 @@ export type OAuthProviderConfig = {
|
|||
export class BaseOAuthClientProvider implements OAuthClientProvider {
|
||||
private capturedAuthUrl: URL | null = null;
|
||||
private eventTarget: EventTarget | null = null;
|
||||
private suppressAuthorizationNavigation = false;
|
||||
|
||||
protected serverUrl: string;
|
||||
protected storage: OAuthStorage;
|
||||
|
|
@ -138,6 +139,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
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.storage.getScope(this.serverUrl);
|
||||
}
|
||||
|
|
@ -217,17 +223,16 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
// Capture URL for return value
|
||||
this.capturedAuthUrl = authorizationUrl;
|
||||
|
||||
// Dispatch event if event target is set
|
||||
if (this.eventTarget) {
|
||||
this.eventTarget.dispatchEvent(
|
||||
new CustomEvent("oauthAuthorizationRequired", {
|
||||
detail: { url: authorizationUrl },
|
||||
}),
|
||||
);
|
||||
if (!this.suppressAuthorizationNavigation) {
|
||||
if (this.eventTarget) {
|
||||
this.eventTarget.dispatchEvent(
|
||||
new CustomEvent("oauthAuthorizationRequired", {
|
||||
detail: { url: authorizationUrl },
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.navigation.navigateToAuthorization(authorizationUrl);
|
||||
}
|
||||
|
||||
// Original navigation behavior
|
||||
this.navigation.navigateToAuthorization(authorizationUrl);
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
|
|
|
|||
43
core/auth/scopes.ts
Normal file
43
core/auth/scopes.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* SEP-2350 scope helpers aligned with v2 `@modelcontextprotocol/client` exports.
|
||||
* On SDK v2 upgrade, delete this module and import `computeScopeUnion` /
|
||||
* `isStrictScopeSuperset` from the client package instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseScopeString,
|
||||
unionAuthorizationScopes,
|
||||
} from "./challenge.js";
|
||||
|
||||
/** Union space-delimited scope strings (order-preserving, deduped). */
|
||||
export function computeScopeUnion(
|
||||
...scopes: ReadonlyArray<string | undefined>
|
||||
): string | undefined {
|
||||
let merged: string | undefined;
|
||||
for (const scope of scopes) {
|
||||
merged = unionAuthorizationScopes(merged, parseScopeString(scope)).join(
|
||||
" ",
|
||||
);
|
||||
if (!merged) {
|
||||
merged = undefined;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `union` contains a scope token not present in `current`.
|
||||
* When the AS omits `scope` on the token response, `current` is empty and any
|
||||
* non-empty union is a strict superset — step-up must re-authorize, not refresh.
|
||||
*/
|
||||
export function isStrictScopeSuperset(
|
||||
union: string | undefined,
|
||||
current: string | undefined,
|
||||
): boolean {
|
||||
if (!union) return false;
|
||||
const currentSet = new Set((current ?? "").split(/\s+/).filter(Boolean));
|
||||
for (const token of union.split(/\s+/)) {
|
||||
if (token && !currentSet.has(token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,4 +1,69 @@
|
|||
import type { CallbackParams } from "./types.js";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
type ZodIssueLike = {
|
||||
path?: unknown[];
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
function isZodIssueArray(value: unknown): value is ZodIssueLike[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
value[0] !== null &&
|
||||
"code" in value[0]
|
||||
);
|
||||
}
|
||||
|
||||
function formatZodIssues(issues: ZodIssueLike[]): string {
|
||||
const tokenResponseIssue = issues.some(
|
||||
(issue) =>
|
||||
Array.isArray(issue.path) &&
|
||||
(issue.path.includes("access_token") ||
|
||||
issue.path.includes("token_type")),
|
||||
);
|
||||
if (tokenResponseIssue) {
|
||||
return "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again.";
|
||||
}
|
||||
return issues
|
||||
.map((issue) => {
|
||||
const path =
|
||||
Array.isArray(issue.path) && issue.path.length
|
||||
? issue.path.join(".")
|
||||
: "input";
|
||||
return `${path}: ${issue.message ?? "invalid"}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable detail for OAuth failure toasts/banners (never raw Zod JSON).
|
||||
*/
|
||||
export function formatOAuthFailureDetail(detail: unknown): string {
|
||||
if (detail instanceof ZodError) {
|
||||
return formatZodIssues(detail.issues);
|
||||
}
|
||||
const raw =
|
||||
detail instanceof Error
|
||||
? detail.message
|
||||
: typeof detail === "string"
|
||||
? detail
|
||||
: String(detail);
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (isZodIssueArray(parsed)) {
|
||||
return formatZodIssues(parsed);
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string as an absolute URL. On failure, throws with `label` and the
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function buildRunnerClientAuthOptions(
|
|||
cliOverrides?: RunnerClientConfigOverrides,
|
||||
): Pick<
|
||||
InspectorClientOptions,
|
||||
"oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth"
|
||||
"oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth" | "directAuthRecovery"
|
||||
> {
|
||||
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
|
||||
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
|
||||
|
|
@ -112,5 +112,6 @@ export function buildRunnerClientAuthOptions(
|
|||
...(clientConfig.enterpriseManagedAuth && {
|
||||
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
|
||||
}),
|
||||
...(oauth && { directAuthRecovery: true }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,11 +118,26 @@ import {
|
|||
UrlElicitationLoopError,
|
||||
} from "./urlElicitation.js";
|
||||
import { ToolCallCancelledError } from "./toolCallCancelledError.js";
|
||||
import type { OAuthConnectionState, OAuthFlowState, OAuthStep } from "../auth/types.js";
|
||||
import type {
|
||||
OAuthConnectionState,
|
||||
OAuthFlowState,
|
||||
OAuthStep,
|
||||
} from "../auth/types.js";
|
||||
import {
|
||||
AuthRecoveryRequiredError,
|
||||
EMA_STEP_UP_PENDING_URL,
|
||||
isAuthChallengeError,
|
||||
isConnectAuthRecoveryError,
|
||||
parseAuthChallengeFromError,
|
||||
type AuthChallenge,
|
||||
type AuthChallengeOutcome,
|
||||
type HandleAuthChallengeOptions,
|
||||
} from "../auth/challenge.js";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
|
||||
import { createFetchTracker } from "./fetchTracking.js";
|
||||
import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js";
|
||||
import { RemoteClientTransport } from "./remote/remoteClientTransport.js";
|
||||
|
||||
/** Internal record for a receiver task (server polls us for status/result). */
|
||||
interface ReceiverTaskRecord {
|
||||
|
|
@ -181,6 +196,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private baseTransport: Transport | null = null;
|
||||
/** True when the cached transport was built with an OAuth authProvider attached. */
|
||||
private transportHasAuthProvider = false;
|
||||
/** Dedupes concurrent ambient auth challenges (reason + scopes). */
|
||||
private ambientAuthChallengeInFlight = new Map<string, Promise<void>>();
|
||||
private pipeStderr: boolean;
|
||||
private initialLoggingLevel?: LoggingLevel;
|
||||
private sample: boolean;
|
||||
|
|
@ -249,6 +266,14 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// Session ID (for OAuth state and saveSession event; persistence is in FetchRequestLogState)
|
||||
private sessionId?: string;
|
||||
private transportConfig: MCPServerConfig;
|
||||
/** null until first transport is built; then true for in-process OAuth runners. */
|
||||
private directAuthRecoveryActive: boolean | null = null;
|
||||
/**
|
||||
* Opt-in from {@link InspectorClientOptions.directAuthRecovery}: when true and
|
||||
* the live transport is direct (not {@link RemoteClientTransport}), RPCs use
|
||||
* fetch intercept + {@link withDirectAuthRecovery}.
|
||||
*/
|
||||
private readonly directAuthRecovery: boolean;
|
||||
|
||||
constructor(
|
||||
transportConfig: MCPServerConfig,
|
||||
|
|
@ -276,6 +301,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
? options.defaultMetadata
|
||||
: undefined;
|
||||
this.serverSettings = options.serverSettings;
|
||||
this.directAuthRecovery = options.directAuthRecovery ?? false;
|
||||
// Only set roots if explicitly provided (even if empty array) - this enables roots capability
|
||||
this.roots = options.roots;
|
||||
// Initialize listChangedNotifications config (default: all enabled)
|
||||
|
|
@ -426,7 +452,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
};
|
||||
this.dispatchTypedEvent("message", entry);
|
||||
},
|
||||
trackNotification: (message: JSONRPCNotification, origin: MessageOrigin) => {
|
||||
trackNotification: (
|
||||
message: JSONRPCNotification,
|
||||
origin: MessageOrigin,
|
||||
) => {
|
||||
const entry: MessageEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(),
|
||||
|
|
@ -761,12 +790,40 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
await oauthManager.createOAuthProviderForTransport();
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.directAuthRecovery &&
|
||||
this.directAuthRecoveryActive !== false &&
|
||||
this.isHttpOAuthConfig() &&
|
||||
oauthManager &&
|
||||
transportOptions.authProvider
|
||||
) {
|
||||
transportOptions.interceptAuthChallenges = true;
|
||||
}
|
||||
this.transportHasAuthProvider = !!transportOptions.authProvider;
|
||||
const { transport: baseTransport } = this.transportClientFactory(
|
||||
this.transportConfig,
|
||||
transportOptions,
|
||||
);
|
||||
this.baseTransport = baseTransport;
|
||||
if (this.directAuthRecovery) {
|
||||
this.directAuthRecoveryActive = !(
|
||||
baseTransport instanceof RemoteClientTransport
|
||||
);
|
||||
}
|
||||
if (
|
||||
baseTransport instanceof RemoteClientTransport &&
|
||||
oauthManager &&
|
||||
this.isHttpOAuthConfig()
|
||||
) {
|
||||
baseTransport.setAuthRecovery({
|
||||
handleAuthChallenge: (challenge, options) =>
|
||||
oauthManager.handleAuthChallenge(challenge, options),
|
||||
pushAuthState: () => this.pushRemoteAuthState(),
|
||||
});
|
||||
baseTransport.setOnAuthChallenge((challenge) => {
|
||||
void this.handleAmbientAuthChallenge(challenge);
|
||||
});
|
||||
}
|
||||
const messageTracking = this.createMessageTrackingCallbacks();
|
||||
this.transport = new MessageTrackingTransport(
|
||||
baseTransport,
|
||||
|
|
@ -789,38 +846,38 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// connect() starts clean and the upstream socket isn't left hanging.
|
||||
const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0;
|
||||
const connectPromise = this.client.connect(this.transport);
|
||||
if (connectTimeoutMs > 0) {
|
||||
// Absorb any late rejection from `connectPromise` — when the timeout
|
||||
// wins the race and `disconnect()` tears the transport down, real
|
||||
// transports (SSE / streamable-http) reject the in-flight handshake
|
||||
// *after* Promise.race has already settled. Without a handler here
|
||||
// Node emits an unhandledRejection warning (and in test runs vitest
|
||||
// can surface it as a suite failure). Only needed on the race path;
|
||||
// the await-only branch below propagates rejection cleanly through
|
||||
// the outer try/catch.
|
||||
connectPromise.catch(() => {});
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Connection timed out after ${connectTimeoutMs} ms`,
|
||||
const runConnect = async (): Promise<void> => {
|
||||
if (connectTimeoutMs > 0) {
|
||||
connectPromise.catch(() => {});
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Connection timed out after ${connectTimeoutMs} ms`,
|
||||
),
|
||||
),
|
||||
),
|
||||
connectTimeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([connectPromise, timeoutPromise]);
|
||||
} catch (err) {
|
||||
await this.disconnect().catch(() => {});
|
||||
throw err;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
connectTimeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([connectPromise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
} else {
|
||||
await connectPromise;
|
||||
}
|
||||
} else {
|
||||
await connectPromise;
|
||||
};
|
||||
|
||||
try {
|
||||
await this.invokeMcpClient(runConnect);
|
||||
} catch (err) {
|
||||
if (connectTimeoutMs > 0) {
|
||||
await this.disconnect().catch(() => {});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this.status = "connected";
|
||||
this.dispatchTypedEvent("statusChange", this.status);
|
||||
|
|
@ -1116,8 +1173,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// and routing work, and we inject the caller's progressToken into dispatched events.
|
||||
}
|
||||
} catch (error) {
|
||||
this.status = "error";
|
||||
this.dispatchTypedEvent("statusChange", this.status);
|
||||
if (!isConnectAuthRecoveryError(error)) {
|
||||
this.status = "error";
|
||||
this.dispatchTypedEvent("statusChange", this.status);
|
||||
}
|
||||
if (this.baseTransport && !this.transportHasAuthProvider) {
|
||||
await this.dropCachedTransport();
|
||||
}
|
||||
|
|
@ -1221,7 +1280,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.instructions = undefined;
|
||||
this.protocolVersion = undefined;
|
||||
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
|
||||
this.dispatchTypedEvent("pendingElicitationsChange", this.pendingElicitations);
|
||||
this.dispatchTypedEvent(
|
||||
"pendingElicitationsChange",
|
||||
this.pendingElicitations,
|
||||
);
|
||||
this.dispatchTypedEvent("capabilitiesChange", this.capabilities);
|
||||
this.dispatchTypedEvent("serverInfoChange", this.serverInfo);
|
||||
this.dispatchTypedEvent("instructionsChange", this.instructions);
|
||||
|
|
@ -1558,9 +1620,11 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
};
|
||||
const response = await this.client.listTools(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
const response = await this.invokeMcpClient(() =>
|
||||
this.client!.listTools(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
),
|
||||
);
|
||||
const tools = [...(response.tools || [])];
|
||||
return { tools, nextCursor: response.nextCursor };
|
||||
|
|
@ -1803,13 +1867,17 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// Both branches yield a CallToolResult: request() parsed it with
|
||||
// CallToolResultSchema above, callTool() returns the same shape — so the
|
||||
// `as CallToolResult` casts below are safe.
|
||||
const result = options?.skipOutputValidation
|
||||
? await client.request(
|
||||
{ method: "tools/call", params: callParams },
|
||||
CallToolResultSchema,
|
||||
requestOptions,
|
||||
)
|
||||
: await client.callTool(callParams, undefined, requestOptions);
|
||||
const result = await this.invokeMcpClient(
|
||||
() =>
|
||||
options?.skipOutputValidation
|
||||
? client.request(
|
||||
{ method: "tools/call", params: callParams },
|
||||
CallToolResultSchema,
|
||||
requestOptions,
|
||||
)
|
||||
: client.callTool(callParams, undefined, requestOptions),
|
||||
{ method: "tools/call", toolName: tool.name },
|
||||
);
|
||||
|
||||
// On the bypass path the result was delivered without the SDK's strict
|
||||
// output validation. Run that check ourselves, non-fatally, so callers can
|
||||
|
|
@ -2197,9 +2265,11 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
};
|
||||
const response = await this.client.listResources(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
const response = await this.invokeMcpClient(() =>
|
||||
this.client!.listResources(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
),
|
||||
);
|
||||
return {
|
||||
resources: response.resources || [],
|
||||
|
|
@ -2225,9 +2295,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
uri,
|
||||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
};
|
||||
const result = await this.client.readResource(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
const result = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.readResource(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
),
|
||||
{ method: "resources/read" },
|
||||
);
|
||||
const invocation: ResourceReadInvocation = {
|
||||
result,
|
||||
|
|
@ -2317,9 +2391,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
};
|
||||
const response = await this.client.listResourceTemplates(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
const response = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.listResourceTemplates(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
),
|
||||
{ method: "resources/templates/list" },
|
||||
);
|
||||
return {
|
||||
resourceTemplates: response.resourceTemplates || [],
|
||||
|
|
@ -2345,9 +2423,11 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
};
|
||||
const response = await this.client.listPrompts(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
const response = await this.invokeMcpClient(() =>
|
||||
this.client!.listPrompts(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
),
|
||||
);
|
||||
return {
|
||||
prompts: response.prompts || [],
|
||||
|
|
@ -2380,9 +2460,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
};
|
||||
|
||||
const result = await this.client.getPrompt(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
const result = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.getPrompt(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
),
|
||||
{ method: "prompts/get", toolName: name },
|
||||
);
|
||||
|
||||
const invocation: PromptGetInvocation = {
|
||||
|
|
@ -2437,9 +2521,17 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
};
|
||||
|
||||
const response = await this.client.complete(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
const response = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.complete(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
),
|
||||
{
|
||||
method: "completion/complete",
|
||||
toolName:
|
||||
ref.type === "ref/prompt" ? ref.name : ref.uri,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -2721,10 +2813,239 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Completes OAuth flow with authorization code from the redirect callback.
|
||||
* Satisfy a mid-session auth challenge (token refresh, step-up, or interactive re-auth).
|
||||
*/
|
||||
async completeOAuthFlow(authorizationCode: string): Promise<void> {
|
||||
return this.ensureOAuthManager().completeOAuthFlow(authorizationCode);
|
||||
async handleAuthChallenge(
|
||||
challenge: AuthChallenge,
|
||||
options?: HandleAuthChallengeOptions,
|
||||
): Promise<AuthChallengeOutcome> {
|
||||
return this.ensureOAuthManager().handleAuthChallenge(challenge, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read OAuth storage and test whether a challenge is already satisfied.
|
||||
* See {@link OAuthManager.checkAuthChallengeSatisfied}.
|
||||
*/
|
||||
async checkAuthChallengeSatisfied(
|
||||
challenge: AuthChallenge,
|
||||
): Promise<boolean> {
|
||||
return this.ensureOAuthManager().checkAuthChallengeSatisfied(challenge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push recovered OAuth auth state to the remote backend (same MCP session).
|
||||
*/
|
||||
async pushRemoteAuthState(): Promise<void> {
|
||||
if (!(this.baseTransport instanceof RemoteClientTransport)) {
|
||||
return;
|
||||
}
|
||||
await this.baseTransport.pushAuthState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an ambient (SSE) auth challenge when no command-scoped send is active.
|
||||
* Recovers session tokens on the remote backend; does not retry RPCs.
|
||||
*/
|
||||
async handleAmbientAuthChallenge(challenge: AuthChallenge): Promise<void> {
|
||||
const key = this.ambientAuthChallengeKey(challenge);
|
||||
const existing = this.ambientAuthChallengeInFlight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.runAmbientAuthChallenge(challenge);
|
||||
this.ambientAuthChallengeInFlight.set(key, promise);
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
if (this.ambientAuthChallengeInFlight.get(key) === promise) {
|
||||
this.ambientAuthChallengeInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runAmbientAuthChallenge(
|
||||
challenge: AuthChallenge,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.dispatchTypedEvent("authChallengeAmbient", { challenge });
|
||||
const oauthManager = this.oauthManager;
|
||||
if (!oauthManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await oauthManager.handleAuthChallenge(challenge);
|
||||
if (outcome.kind === "satisfied") {
|
||||
if (this.baseTransport instanceof RemoteClientTransport) {
|
||||
await this.pushRemoteAuthState();
|
||||
} else {
|
||||
await this.reconnectAfterAuthRecovery();
|
||||
}
|
||||
this.dispatchTypedEvent("authChallengeRecovered", { challenge });
|
||||
} else if (outcome.kind === "step_up_confirm") {
|
||||
this.dispatchTypedEvent("authChallengeInteractive", {
|
||||
challenge: outcome.challenge,
|
||||
authorizationUrl: EMA_STEP_UP_PENDING_URL,
|
||||
});
|
||||
} else if (outcome.kind === "interactive") {
|
||||
this.dispatchTypedEvent("authChallengeInteractive", {
|
||||
challenge: outcome.challenge,
|
||||
authorizationUrl: outcome.authorizationUrl,
|
||||
});
|
||||
} else {
|
||||
this.dispatchTypedEvent("oauthError", { error: outcome.error });
|
||||
}
|
||||
} catch (error) {
|
||||
this.dispatchTypedEvent("oauthError", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private ambientAuthChallengeKey(challenge: AuthChallenge): string {
|
||||
const requiredScopes = [...(challenge.requiredScopes ?? [])]
|
||||
.sort()
|
||||
.join(" ");
|
||||
const authorizationScopes = [...(challenge.authorizationScopes ?? [])]
|
||||
.sort()
|
||||
.join(" ");
|
||||
return `${challenge.reason}:${requiredScopes}:${authorizationScopes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full disconnect + reconnect after ambient auth recovery on direct transports.
|
||||
*/
|
||||
private async reconnectAfterAuthRecovery(): Promise<void> {
|
||||
await this.disconnect().catch(() => {});
|
||||
await this.dropCachedTransport();
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
/** Direct (non-remote) OAuth transports recover via fetch intercept + handleAuthChallenge. */
|
||||
private usesDirectAuthRecovery(): boolean {
|
||||
return this.directAuthRecovery && this.directAuthRecoveryActive === true;
|
||||
}
|
||||
|
||||
private async withDirectAuthRecovery<T>(
|
||||
operation: () => Promise<T>,
|
||||
context?: { method?: string; toolName?: string },
|
||||
attempt = 0,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err) {
|
||||
if (attempt >= 1 || !this.usesDirectAuthRecovery()) {
|
||||
throw err;
|
||||
}
|
||||
if (!isAuthChallengeError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const challenge = parseAuthChallengeFromError(err, context);
|
||||
if (!challenge) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (context?.method || context?.toolName) {
|
||||
this.dispatchTypedEvent("authChallengeCommand", { challenge });
|
||||
} else {
|
||||
this.dispatchTypedEvent("authChallengeAmbient", { challenge });
|
||||
}
|
||||
const outcome = await this.handleAuthChallenge(challenge);
|
||||
if (outcome.kind === "satisfied") {
|
||||
// Reconnect aborts activeToolCallAbortController; clear it so callTool
|
||||
// retries are not immediately rejected with "Disconnected".
|
||||
if (this.activeToolCallAbortController) {
|
||||
this.activeToolCallAbortController = undefined;
|
||||
}
|
||||
await this.reconnectAfterAuthRecovery();
|
||||
this.dispatchTypedEvent("authChallengeRecovered", { challenge });
|
||||
return this.withDirectAuthRecovery(operation, context, attempt + 1);
|
||||
}
|
||||
if (outcome.kind === "step_up_confirm") {
|
||||
throw new AuthRecoveryRequiredError(
|
||||
EMA_STEP_UP_PENDING_URL,
|
||||
outcome.challenge,
|
||||
{ emaStepUpConfirm: true },
|
||||
);
|
||||
}
|
||||
if (outcome.kind === "interactive") {
|
||||
throw new AuthRecoveryRequiredError(
|
||||
outcome.authorizationUrl,
|
||||
outcome.challenge,
|
||||
);
|
||||
}
|
||||
this.dispatchTypedEvent("oauthError", { error: outcome.error });
|
||||
throw outcome.error;
|
||||
}
|
||||
}
|
||||
|
||||
private async invokeMcpClient<T>(
|
||||
operation: () => Promise<T>,
|
||||
context?: { method?: string; toolName?: string },
|
||||
): Promise<T> {
|
||||
if (!this.usesDirectAuthRecovery()) {
|
||||
return operation();
|
||||
}
|
||||
return this.withDirectAuthRecovery(operation, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes OAuth flow with authorization code from the redirect callback.
|
||||
* Direct transports reconnect after token exchange so the live MCP session
|
||||
* picks up the new Bearer token (mirrors silent recovery reconnect).
|
||||
*/
|
||||
async completeOAuthFlow(authorizationCode: string, iss?: string): Promise<void> {
|
||||
await this.ensureOAuthManager().completeOAuthFlow(authorizationCode, iss);
|
||||
if (this.usesDirectAuthRecovery()) {
|
||||
await this.reconnectAfterAuthRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the authorization server for interactive recovery.
|
||||
*/
|
||||
async beginInteractiveAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
return this.ensureOAuthManager().beginInteractiveAuthorization(
|
||||
authorizationUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/** Remote Hono session id when using {@link RemoteClientTransport}. */
|
||||
getRemoteBackendSessionId(): string | undefined {
|
||||
if (this.baseTransport instanceof RemoteClientTransport) {
|
||||
return this.baseTransport.getRemoteBackendSessionId();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish OAuth after a full-page redirect and reconnect (or reattach) the MCP session.
|
||||
*/
|
||||
async resumeAfterOAuth(
|
||||
authorizationCode: string,
|
||||
options?: { remoteSessionId?: string },
|
||||
): Promise<void> {
|
||||
await this.completeOAuthFlow(authorizationCode);
|
||||
|
||||
const remoteSessionId = options?.remoteSessionId;
|
||||
const transport = this.baseTransport;
|
||||
|
||||
if (remoteSessionId && transport instanceof RemoteClientTransport) {
|
||||
try {
|
||||
await transport.attachToSession(remoteSessionId);
|
||||
await transport.pushAuthState();
|
||||
if (this.status !== "connected") {
|
||||
await this.connect();
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// Session expired during OAuth round trip — fall back to fresh connect.
|
||||
}
|
||||
}
|
||||
|
||||
if (this.status !== "connected") {
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,6 +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 { AuthChallenge } from "../auth/challenge.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"> & {
|
||||
|
|
@ -148,6 +149,17 @@ export interface InspectorClientEventMap {
|
|||
oauthComplete: { tokens: OAuthTokens };
|
||||
oauthAuthorizationRequired: { url: URL };
|
||||
oauthError: { error: Error };
|
||||
/** Ambient (SSE) auth challenge while no command-scoped send is active. */
|
||||
authChallengeAmbient: { challenge: AuthChallenge };
|
||||
/** Command-scoped direct-transport auth challenge (no ambient toast). */
|
||||
authChallengeCommand: { challenge: AuthChallenge };
|
||||
/** Ambient auth recovery completed (remote auth state pushed). */
|
||||
authChallengeRecovered: { challenge: AuthChallenge };
|
||||
/** Interactive auth required; App orchestrates redirect (step-up modal or reauth). */
|
||||
authChallengeInteractive: {
|
||||
challenge: AuthChallenge;
|
||||
authorizationUrl: URL;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
34
core/mcp/node/authChallengeFetch.ts
Normal file
34
core/mcp/node/authChallengeFetch.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
AuthChallengeError,
|
||||
parseAuthChallengeFromResponse,
|
||||
} from "../../auth/challenge.js";
|
||||
|
||||
/**
|
||||
* Wrap fetch so MCP HTTP 401/403 responses become {@link AuthChallengeError}
|
||||
* before the SDK invokes `auth()` on a frozen remote token provider.
|
||||
*/
|
||||
export function createAuthChallengeInterceptFetch(
|
||||
baseFetch: typeof fetch,
|
||||
): typeof fetch {
|
||||
return async (input, init) => {
|
||||
const response = await baseFetch(input, init);
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const challenge = parseAuthChallengeFromResponse(response);
|
||||
if (!challenge) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Release the connection before throwing so the SDK transport is not left
|
||||
// with a half-read 401/403 body on streamable HTTP.
|
||||
await response.body?.cancel().catch(() => {});
|
||||
|
||||
throw new AuthChallengeError(
|
||||
challenge,
|
||||
response.status,
|
||||
`MCP auth challenge (${response.status})`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { createFetchTracker } from "../fetchTracking.js";
|
||||
import { createAuthChallengeInterceptFetch } from "./authChallengeFetch.js";
|
||||
|
||||
/**
|
||||
* Build the wire `headers` record from `settings.headers`, dropping rows with
|
||||
|
|
@ -46,9 +47,13 @@ export function createTransportNode(
|
|||
onFetchResponseBody,
|
||||
authProvider,
|
||||
settings,
|
||||
interceptAuthChallenges = false,
|
||||
} = options;
|
||||
|
||||
const baseFetch = optionsFetchFn ?? globalThis.fetch;
|
||||
const fetchWithOptionalAuthIntercept = interceptAuthChallenges
|
||||
? createAuthChallengeInterceptFetch(baseFetch)
|
||||
: baseFetch;
|
||||
|
||||
if (serverType === "stdio") {
|
||||
const stdioConfig = config as StdioServerConfig;
|
||||
|
|
@ -79,7 +84,8 @@ export function createTransportNode(
|
|||
const url = new URL(sseConfig.url);
|
||||
|
||||
const sseFetch =
|
||||
(sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch;
|
||||
(sseConfig.eventSourceInit?.fetch as typeof fetch) ||
|
||||
fetchWithOptionalAuthIntercept;
|
||||
const trackedFetch = onFetchRequest
|
||||
? createFetchTracker(sseFetch, {
|
||||
trackRequest: onFetchRequest,
|
||||
|
|
@ -101,11 +107,11 @@ export function createTransportNode(
|
|||
};
|
||||
|
||||
const postFetch = onFetchRequest
|
||||
? createFetchTracker(baseFetch, {
|
||||
? createFetchTracker(fetchWithOptionalAuthIntercept, {
|
||||
trackRequest: onFetchRequest,
|
||||
updateResponseBody: onFetchResponseBody,
|
||||
})
|
||||
: baseFetch;
|
||||
: fetchWithOptionalAuthIntercept;
|
||||
|
||||
const transport = new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
|
|
@ -128,11 +134,11 @@ export function createTransportNode(
|
|||
};
|
||||
|
||||
const transportFetch = onFetchRequest
|
||||
? createFetchTracker(baseFetch, {
|
||||
? createFetchTracker(fetchWithOptionalAuthIntercept, {
|
||||
trackRequest: onFetchRequest,
|
||||
updateResponseBody: onFetchResponseBody,
|
||||
})
|
||||
: baseFetch;
|
||||
: fetchWithOptionalAuthIntercept;
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
authProvider,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { BaseOAuthClientProvider } from "../auth/providers.js";
|
|||
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
|
||||
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.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 { mcpAuth } from "../auth/mcpAuth.js";
|
||||
import { parseOAuthState } from "../auth/utils.js";
|
||||
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
|
||||
import type { ClientConfig } from "../client/types.js";
|
||||
|
|
@ -24,12 +24,27 @@ import {
|
|||
import {
|
||||
buildOAuthConnectionState,
|
||||
hasPersistedOAuthServerState,
|
||||
isAccessTokenUsable,
|
||||
isServerOAuthConfigured,
|
||||
protocolFromOAuthConfig,
|
||||
} from "../auth/connection-state.js";
|
||||
import { ensureCimdClientRegistration } from "../auth/cimd.js";
|
||||
import type { OAuthConnectionState } from "../auth/types.js";
|
||||
import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js";
|
||||
import type {
|
||||
AuthChallenge,
|
||||
AuthChallengeOutcome,
|
||||
HandleAuthChallengeOptions,
|
||||
} from "../auth/challenge.js";
|
||||
import {
|
||||
parseScopeString,
|
||||
unionAuthorizationScopes,
|
||||
} from "../auth/challenge.js";
|
||||
import {
|
||||
computeScopeUnion,
|
||||
isStrictScopeSuperset,
|
||||
} from "../auth/scopes.js";
|
||||
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
|
||||
import type {
|
||||
InspectorClientEnvironment,
|
||||
InspectorClientOptions,
|
||||
|
|
@ -60,10 +75,14 @@ export class OAuthManager {
|
|||
private params: OAuthManagerParams;
|
||||
private oauthConfig: OAuthManagerConfig;
|
||||
private oauthFlowState: OAuthFlowState | null = null;
|
||||
/** SEP-2350 union scope pending until interactive step-up completes. */
|
||||
private pendingAuthorizationScope: string | undefined;
|
||||
private authChallengeMutex: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(params: OAuthManagerParams) {
|
||||
this.params = params;
|
||||
this.oauthConfig = { ...params.initialConfig };
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
}
|
||||
|
||||
setOAuthConfig(config: {
|
||||
|
|
@ -104,7 +123,8 @@ export class OAuthManager {
|
|||
|
||||
provider.setEventTarget(this.params.getEventTarget());
|
||||
|
||||
if (this.oauthConfig.scope) {
|
||||
const storedScope = this.oauthConfig.storage.getScope(serverUrl);
|
||||
if (storedScope === undefined && this.oauthConfig.scope) {
|
||||
await provider.saveScope(this.oauthConfig.scope);
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +166,11 @@ export class OAuthManager {
|
|||
idp,
|
||||
resourceClientId: this.oauthConfig.clientId,
|
||||
resourceClientSecret: this.oauthConfig.clientSecret,
|
||||
scope: this.oauthConfig.scope,
|
||||
scope:
|
||||
computeScopeUnion(
|
||||
this.oauthConfig.scope,
|
||||
this.oauthConfig.storage.getScope(this.getServerUrl()),
|
||||
) || this.oauthConfig.scope,
|
||||
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(),
|
||||
storage: this.oauthConfig.storage,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
|
|
@ -181,12 +205,8 @@ export class OAuthManager {
|
|||
}
|
||||
}
|
||||
|
||||
this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl);
|
||||
this.oauthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "authorization_code",
|
||||
authorizationUrl,
|
||||
};
|
||||
this.requireNavigation().navigateToAuthorization(authorizationUrl);
|
||||
await this.recordAuthorizationCodeFlowState(authorizationUrl);
|
||||
return authorizationUrl;
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +226,7 @@ export class OAuthManager {
|
|||
fetchFn: this.params.effectiveAuthFetch,
|
||||
});
|
||||
|
||||
const result = await auth(provider, {
|
||||
const result = await mcpAuth(provider, {
|
||||
serverUrl,
|
||||
scope: provider.scope,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
|
|
@ -241,14 +261,28 @@ export class OAuthManager {
|
|||
return capturedUrl;
|
||||
}
|
||||
|
||||
async completeOAuthFlow(authorizationCode: string): Promise<void> {
|
||||
async completeOAuthFlow(
|
||||
authorizationCode: string,
|
||||
iss?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (this.isEnterpriseManaged()) {
|
||||
const config = this.getEmaFlowConfig();
|
||||
const scopeForMint =
|
||||
this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope;
|
||||
const config = scopeForMint
|
||||
? { ...this.getEmaFlowConfig(), scope: scopeForMint }
|
||||
: this.getEmaFlowConfig();
|
||||
const tokens = await completeEmaIdpAuthorizationAndMint(
|
||||
config,
|
||||
authorizationCode,
|
||||
);
|
||||
if (this.pendingAuthorizationScope) {
|
||||
await this.oauthConfig.storage!.saveScope(
|
||||
this.getServerUrl(),
|
||||
this.pendingAuthorizationScope,
|
||||
);
|
||||
}
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
const completedAt = Date.now();
|
||||
this.oauthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
|
|
@ -263,9 +297,10 @@ export class OAuthManager {
|
|||
const provider = await this.createOAuthProvider();
|
||||
const serverUrl = this.getServerUrl();
|
||||
|
||||
const result = await auth(provider, {
|
||||
const result = await mcpAuth(provider, {
|
||||
serverUrl,
|
||||
authorizationCode,
|
||||
iss,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
});
|
||||
|
||||
|
|
@ -280,6 +315,13 @@ export class OAuthManager {
|
|||
throw new Error("Failed to retrieve tokens after authorization");
|
||||
}
|
||||
|
||||
const scopeToPersist =
|
||||
this.pendingAuthorizationScope ?? tokens.scope;
|
||||
if (scopeToPersist) {
|
||||
await provider.saveScope(scopeToPersist);
|
||||
}
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
|
||||
const clientInfo = await provider.clientInformation();
|
||||
const completedAt = Date.now();
|
||||
this.oauthFlowState = this.oauthFlowState
|
||||
|
|
@ -300,6 +342,7 @@ export class OAuthManager {
|
|||
|
||||
this.params.dispatchOAuthComplete({ tokens });
|
||||
} catch (error) {
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
this.params.dispatchOAuthError({
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
|
|
@ -329,6 +372,7 @@ export class OAuthManager {
|
|||
this.oauthConfig.storage.clear(serverUrl);
|
||||
|
||||
this.oauthFlowState = null;
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
}
|
||||
|
||||
async isOAuthAuthorized(): Promise<boolean> {
|
||||
|
|
@ -382,6 +426,387 @@ export class OAuthManager {
|
|||
return tokens !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read persisted OAuth state and determine whether `challenge` is already
|
||||
* satisfied without an authorization-server round-trip.
|
||||
*
|
||||
* Returns `true` for `insufficient_scope` when stored + token scope cover the
|
||||
* SEP-2350 union. For `token_expired` / `unauthorized` / `invalid_token`,
|
||||
* returns `true` when a usable access token is already in storage.
|
||||
*/
|
||||
async checkAuthChallengeSatisfied(
|
||||
challenge: AuthChallenge,
|
||||
): Promise<boolean> {
|
||||
const storage = this.oauthConfig.storage;
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverUrl = this.getServerUrl();
|
||||
const tokens = await storage.getTokens(serverUrl);
|
||||
if (!tokens?.access_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (challenge.reason !== "insufficient_scope") {
|
||||
return (
|
||||
challenge.reason === "token_expired" ||
|
||||
challenge.reason === "unauthorized" ||
|
||||
challenge.reason === "invalid_token"
|
||||
) && isAccessTokenUsable(tokens);
|
||||
}
|
||||
|
||||
const enriched = await this.enrichChallengeWithAuthorizationScopes(
|
||||
challenge,
|
||||
tokens.scope,
|
||||
);
|
||||
const scopeForAuth =
|
||||
enriched.authorizationScopes?.join(" ") ??
|
||||
enriched.requiredScopes?.join(" ");
|
||||
if (!scopeForAuth?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const effectiveScope = computeScopeUnion(
|
||||
storage.getScope(serverUrl),
|
||||
tokens.scope,
|
||||
);
|
||||
return !isStrictScopeSuperset(scopeForAuth, effectiveScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Satisfy a mid-session auth challenge when possible (silent refresh/re-mint or
|
||||
* interactive redirect).
|
||||
*
|
||||
* Only `insufficient_scope` short-circuits on {@link checkAuthChallengeSatisfied}
|
||||
* here — `token_expired` / `unauthorized` still attempt silent refresh even when
|
||||
* storage holds a locally-valid token (the resource server may have invalidated it).
|
||||
* Callers use {@link checkAuthChallengeSatisfied} directly before visible OAuth.
|
||||
*
|
||||
* Recovery is serialized per server so parallel challenges cannot race on
|
||||
* `pendingAuthorizationScope` or OAuth provider state.
|
||||
*/
|
||||
async handleAuthChallenge(
|
||||
challenge: AuthChallenge,
|
||||
options?: HandleAuthChallengeOptions,
|
||||
): Promise<AuthChallengeOutcome> {
|
||||
if (
|
||||
challenge.reason === "insufficient_scope" &&
|
||||
(await this.checkAuthChallengeSatisfied(challenge))
|
||||
) {
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
|
||||
const prior = this.authChallengeMutex;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
this.authChallengeMutex = gate;
|
||||
await prior;
|
||||
try {
|
||||
if (
|
||||
challenge.reason === "insufficient_scope" &&
|
||||
(await this.checkAuthChallengeSatisfied(challenge))
|
||||
) {
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
return await this.runHandleAuthChallenge(challenge, options);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
private async runHandleAuthChallenge(
|
||||
challenge: AuthChallenge,
|
||||
options?: HandleAuthChallengeOptions,
|
||||
): Promise<AuthChallengeOutcome> {
|
||||
if (this.isEnterpriseManaged()) {
|
||||
return this.handleEnterpriseManagedAuthChallenge(challenge, options);
|
||||
}
|
||||
return this.handleStandardAuthChallenge(challenge);
|
||||
}
|
||||
|
||||
private async enrichChallengeWithAuthorizationScopes(
|
||||
challenge: AuthChallenge,
|
||||
grantedTokenScope?: string,
|
||||
): Promise<AuthChallenge> {
|
||||
if (challenge.reason !== "insufficient_scope") {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
const storage = this.oauthConfig.storage;
|
||||
const serverUrl = this.getServerUrl();
|
||||
|
||||
if (grantedTokenScope === undefined && storage) {
|
||||
const tokens = await storage.getTokens(serverUrl);
|
||||
grantedTokenScope = tokens?.scope;
|
||||
}
|
||||
|
||||
const previousScope = computeScopeUnion(
|
||||
storage?.getScope(serverUrl),
|
||||
grantedTokenScope,
|
||||
);
|
||||
const requiredFromChallenge = challenge.requiredScopes?.filter(Boolean) ?? [];
|
||||
const grantedSet = new Set(parseScopeString(previousScope));
|
||||
const missingRequired = requiredFromChallenge.filter(
|
||||
(scope) => !grantedSet.has(scope),
|
||||
);
|
||||
const requiredScopes =
|
||||
missingRequired.length > 0 ? missingRequired : requiredFromChallenge;
|
||||
|
||||
const authorizationScopes = unionAuthorizationScopes(
|
||||
previousScope,
|
||||
requiredFromChallenge,
|
||||
);
|
||||
|
||||
return {
|
||||
...challenge,
|
||||
requiredScopes,
|
||||
authorizationScopes,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveEmaScopeForChallenge(challenge: AuthChallenge): string | undefined {
|
||||
if (challenge.reason === "insufficient_scope") {
|
||||
const fromChallenge = challenge.requiredScopes?.join(" ").trim();
|
||||
if (fromChallenge) {
|
||||
return fromChallenge;
|
||||
}
|
||||
}
|
||||
return this.oauthConfig.scope?.trim() || undefined;
|
||||
}
|
||||
|
||||
private emaFlowConfigForChallenge(challenge: AuthChallenge): EmaFlowConfig {
|
||||
const base = this.getEmaFlowConfig();
|
||||
const enriched = challenge.authorizationScopes;
|
||||
if (enriched && enriched.length > 0) {
|
||||
return { ...base, scope: enriched.join(" ") };
|
||||
}
|
||||
const scope = this.resolveEmaScopeForChallenge(challenge);
|
||||
return scope ? { ...base, scope } : base;
|
||||
}
|
||||
|
||||
private async handleEnterpriseManagedAuthChallenge(
|
||||
challenge: AuthChallenge,
|
||||
options?: HandleAuthChallengeOptions,
|
||||
): Promise<AuthChallengeOutcome> {
|
||||
const enriched = await this.enrichChallengeWithAuthorizationScopes(challenge);
|
||||
|
||||
if (enriched.reason === "insufficient_scope" && !options?.confirmedStepUp) {
|
||||
return { kind: "step_up_confirm", challenge: enriched };
|
||||
}
|
||||
|
||||
const config = this.emaFlowConfigForChallenge(enriched);
|
||||
|
||||
if (enriched.reason === "insufficient_scope") {
|
||||
const silent = await trySilentEmaAuth(config);
|
||||
if (silent.status === "success") {
|
||||
if (enriched.authorizationScopes?.length) {
|
||||
await this.oauthConfig.storage!.saveScope(
|
||||
this.getServerUrl(),
|
||||
enriched.authorizationScopes.join(" "),
|
||||
);
|
||||
}
|
||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
}
|
||||
if (silent.status === "mint_failed") {
|
||||
return { kind: "failed", error: silent.error };
|
||||
}
|
||||
} else {
|
||||
const tokens = await refreshEmaResourceTokens(config);
|
||||
if (tokens) {
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const authorizationUrl = await startEmaIdpAuthorization(config);
|
||||
if (
|
||||
enriched.reason === "insufficient_scope" &&
|
||||
enriched.authorizationScopes?.length
|
||||
) {
|
||||
this.pendingAuthorizationScope =
|
||||
enriched.authorizationScopes.join(" ");
|
||||
}
|
||||
return { kind: "interactive", authorizationUrl, challenge: enriched };
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "failed",
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStandardAuthChallenge(
|
||||
challenge: AuthChallenge,
|
||||
): Promise<AuthChallengeOutcome> {
|
||||
const provider = await this.createOAuthProvider();
|
||||
const serverUrl = this.getServerUrl();
|
||||
const tokens = await provider.tokens();
|
||||
const enriched = await this.enrichChallengeWithAuthorizationScopes(
|
||||
challenge,
|
||||
tokens?.scope,
|
||||
);
|
||||
|
||||
provider.clearCapturedAuthUrl();
|
||||
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl,
|
||||
provider,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
});
|
||||
|
||||
const scopeForAuth =
|
||||
enriched.reason === "insufficient_scope"
|
||||
? enriched.authorizationScopes?.join(" ")
|
||||
: this.oauthConfig.scope?.trim() ||
|
||||
(enriched.requiredScopes?.length
|
||||
? enriched.requiredScopes.join(" ")
|
||||
: undefined);
|
||||
|
||||
provider.setSuppressAuthorizationNavigation(true);
|
||||
let result: Awaited<ReturnType<typeof mcpAuth>>;
|
||||
try {
|
||||
result = await mcpAuth(provider, {
|
||||
serverUrl,
|
||||
scope: scopeForAuth,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
...(enriched.reason === "insufficient_scope" && {
|
||||
forceReauthorization: isStrictScopeSuperset(
|
||||
scopeForAuth,
|
||||
tokens?.scope,
|
||||
),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
provider.setSuppressAuthorizationNavigation(false);
|
||||
}
|
||||
|
||||
if (result === "AUTHORIZED") {
|
||||
if (enriched.reason === "insufficient_scope") {
|
||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||
if (scopeForAuth) {
|
||||
await provider.saveScope(scopeForAuth);
|
||||
}
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
const forced = await this.tryForceReauthorizationForStepUp(
|
||||
provider,
|
||||
serverUrl,
|
||||
scopeForAuth,
|
||||
enriched,
|
||||
);
|
||||
if (forced) {
|
||||
return forced;
|
||||
}
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new Error(stepUpInsufficientScopeMessage(enriched)),
|
||||
};
|
||||
} else {
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
}
|
||||
|
||||
const capturedUrl = provider.getCapturedAuthUrl();
|
||||
if (!capturedUrl) {
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new Error("Failed to capture authorization URL"),
|
||||
};
|
||||
}
|
||||
|
||||
if (enriched.reason === "insufficient_scope" && scopeForAuth) {
|
||||
this.pendingAuthorizationScope = scopeForAuth;
|
||||
}
|
||||
|
||||
const clientInfo = await provider.clientInformation();
|
||||
await this.recordAuthorizationCodeFlowState(capturedUrl, clientInfo);
|
||||
|
||||
return {
|
||||
kind: "interactive",
|
||||
authorizationUrl: capturedUrl,
|
||||
challenge: enriched,
|
||||
};
|
||||
}
|
||||
|
||||
/** Start interactive OAuth after handleAuthChallenge returns `interactive`. */
|
||||
async beginInteractiveAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
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.requireNavigation().navigateToAuthorization(authorizationUrl);
|
||||
|
||||
const provider = await this.createOAuthProvider();
|
||||
const clientInfo = await provider.clientInformation();
|
||||
await this.recordAuthorizationCodeFlowState(authorizationUrl, clientInfo);
|
||||
|
||||
this.params.dispatchOAuthAuthorizationRequired({ url: authorizationUrl });
|
||||
}
|
||||
|
||||
private requireNavigation(): NonNullable<OAuthManagerConfig["navigation"]> {
|
||||
const navigation = this.oauthConfig.navigation;
|
||||
if (!navigation) {
|
||||
throw new Error("OAuth navigation is required.");
|
||||
}
|
||||
return navigation;
|
||||
}
|
||||
|
||||
private async recordAuthorizationCodeFlowState(
|
||||
authorizationUrl: URL,
|
||||
oauthClientInfo?: OAuthClientInformation | null,
|
||||
): Promise<void> {
|
||||
this.oauthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "authorization_code",
|
||||
authorizationUrl,
|
||||
oauthClientInfo: oauthClientInfo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent refresh returned AUTHORIZED but token scope still lacks the step-up
|
||||
* union — force a fresh authorization redirect.
|
||||
*/
|
||||
private async tryForceReauthorizationForStepUp(
|
||||
provider: BaseOAuthClientProvider,
|
||||
serverUrl: string,
|
||||
scopeForAuth: string | undefined,
|
||||
enriched: AuthChallenge,
|
||||
): Promise<AuthChallengeOutcome | null> {
|
||||
provider.clearCapturedAuthUrl();
|
||||
provider.setSuppressAuthorizationNavigation(true);
|
||||
let result: Awaited<ReturnType<typeof mcpAuth>>;
|
||||
try {
|
||||
result = await mcpAuth(provider, {
|
||||
serverUrl,
|
||||
scope: scopeForAuth,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
forceReauthorization: true,
|
||||
});
|
||||
} finally {
|
||||
provider.setSuppressAuthorizationNavigation(false);
|
||||
}
|
||||
if (result !== "AUTHORIZED") {
|
||||
return null;
|
||||
}
|
||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||
if (scopeForAuth) {
|
||||
await provider.saveScope(scopeForAuth);
|
||||
}
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an OAuth provider for transport auth (connect()).
|
||||
* Used only when isHttpOAuthConfig() is true.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
export {
|
||||
RemoteClientTransport,
|
||||
type RemoteTransportOptions,
|
||||
type AuthRecoveryHandlers,
|
||||
} from "./remoteClientTransport.js";
|
||||
export {
|
||||
createRemoteTransport,
|
||||
|
|
@ -26,6 +27,7 @@ export {
|
|||
export type {
|
||||
RemoteConnectRequest,
|
||||
RemoteConnectResponse,
|
||||
RemoteSendResponse,
|
||||
RemoteEvent,
|
||||
} from "./types.js";
|
||||
export { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV } from "./constants.js";
|
||||
|
|
|
|||
|
|
@ -6,12 +6,21 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|||
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { FetchRequestEntryBase } from "../../types.js";
|
||||
import type { RemoteEvent } from "../types.js";
|
||||
import type { AuthChallenge } from "../../../auth/challenge.js";
|
||||
import { AuthChallengeError } from "../../../auth/challenge.js";
|
||||
import type { RemoteAuthProviderHandle } from "./tokenAuthProvider.js";
|
||||
import type { RemoteAuthState } from "../types.js";
|
||||
|
||||
export interface SessionEvent {
|
||||
type: RemoteEvent["type"];
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
type RequestWait = {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
export class RemoteSession {
|
||||
public readonly sessionId: string;
|
||||
public transport!: Transport;
|
||||
|
|
@ -19,11 +28,31 @@ export class RemoteSession {
|
|||
private eventConsumer: ((event: SessionEvent) => void) | null = null;
|
||||
private transportDead: boolean = false;
|
||||
private transportError: string | null = null;
|
||||
private activeSendCount = 0;
|
||||
/**
|
||||
* Suppress duplicate ambient SSE auth echoes after HTTP command-scoped delivery.
|
||||
* Stale transport `onerror` may arrive after the send completes.
|
||||
*/
|
||||
private authHttpEchoSuppressUntilMs = 0;
|
||||
static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000;
|
||||
private readonly requestWaits = new Map<string | number, RequestWait>();
|
||||
private authProviderHandle: RemoteAuthProviderHandle | null = null;
|
||||
|
||||
constructor(sessionId: string) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
setAuthProviderHandle(handle: RemoteAuthProviderHandle | null): void {
|
||||
this.authProviderHandle = handle;
|
||||
}
|
||||
|
||||
setAuthState(authState: RemoteAuthState): void {
|
||||
if (!this.authProviderHandle) {
|
||||
throw new Error("Session has no OAuth auth provider");
|
||||
}
|
||||
this.authProviderHandle.setAuthState(authState);
|
||||
}
|
||||
|
||||
setTransport(transport: Transport): void {
|
||||
this.transport = transport;
|
||||
}
|
||||
|
|
@ -72,6 +101,119 @@ export class RemoteSession {
|
|||
return this.eventConsumer !== null;
|
||||
}
|
||||
|
||||
beginSend(): void {
|
||||
this.activeSendCount++;
|
||||
}
|
||||
|
||||
endSend(): void {
|
||||
if (this.activeSendCount > 0) {
|
||||
this.activeSendCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Command-scoped auth was returned on HTTP; suppress async onerror echo on SSE. */
|
||||
noteAuthChallengeDeliveredViaHttp(): void {
|
||||
this.extendAuthHttpEchoSuppression();
|
||||
}
|
||||
|
||||
private extendAuthHttpEchoSuppression(): void {
|
||||
this.authHttpEchoSuppressUntilMs =
|
||||
Date.now() + RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS;
|
||||
}
|
||||
|
||||
private shouldSuppressAuthEcho(): boolean {
|
||||
return Date.now() < this.authHttpEchoSuppressUntilMs;
|
||||
}
|
||||
|
||||
hasActiveSend(): boolean {
|
||||
return this.activeSendCount > 0;
|
||||
}
|
||||
|
||||
pushAuthChallenge(challenge: AuthChallenge): void {
|
||||
this.pushEvent({ type: "auth_challenge", data: challenge });
|
||||
}
|
||||
|
||||
/**
|
||||
* Streamable HTTP `transport.send()` can return before the JSON-RPC response
|
||||
* arrives. The remote send handler awaits this so `/api/mcp/send` returns
|
||||
* `auth_challenge` or `ok: true` only after the MCP round-trip (or auth error).
|
||||
*/
|
||||
waitForRequestResponse(
|
||||
requestId: string | number,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.requestWaits.delete(requestId);
|
||||
reject(
|
||||
new Error(
|
||||
`MCP request ${String(requestId)} timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
|
||||
this.requestWaits.set(requestId, {
|
||||
resolve: () => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
reject: (error) => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancelRequestWait(requestId: string | number): void {
|
||||
this.requestWaits.delete(requestId);
|
||||
}
|
||||
|
||||
rejectActiveRequestWaits(error: Error): void {
|
||||
for (const wait of this.requestWaits.values()) {
|
||||
wait.reject(error);
|
||||
}
|
||||
this.requestWaits.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth errors from the MCP transport.
|
||||
* - During an active send: command path owns delivery (HTTP); never SSE.
|
||||
* - After HTTP already returned auth_challenge: swallow async onerror echo.
|
||||
* - Otherwise: ambient SSE (idle session).
|
||||
*/
|
||||
handleTransportAuthError(error: unknown): error is AuthChallengeError {
|
||||
if (!(error instanceof AuthChallengeError)) {
|
||||
return false;
|
||||
}
|
||||
this.rejectActiveRequestWaits(error);
|
||||
if (this.hasActiveSend()) {
|
||||
this.extendAuthHttpEchoSuppression();
|
||||
return true;
|
||||
}
|
||||
if (this.shouldSuppressAuthEcho()) {
|
||||
return true;
|
||||
}
|
||||
this.pushAuthChallenge(error.authChallenge);
|
||||
return true;
|
||||
}
|
||||
|
||||
private settleRequestWait(requestId: string | number): void {
|
||||
const wait = this.requestWaits.get(requestId);
|
||||
if (!wait) {
|
||||
return;
|
||||
}
|
||||
this.requestWaits.delete(requestId);
|
||||
wait.resolve();
|
||||
}
|
||||
|
||||
pushEvent(event: SessionEvent): void {
|
||||
if (this.eventConsumer) {
|
||||
this.eventConsumer(event);
|
||||
|
|
@ -81,6 +223,14 @@ export class RemoteSession {
|
|||
}
|
||||
|
||||
onMessage(message: JSONRPCMessage): void {
|
||||
if (
|
||||
"id" in message &&
|
||||
message.id !== null &&
|
||||
message.id !== undefined &&
|
||||
("result" in message || "error" in message)
|
||||
) {
|
||||
this.settleRequestWait(message.id);
|
||||
}
|
||||
this.pushEvent({ type: "message", data: message });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,13 @@ import type { Context, Env, Next } from "hono";
|
|||
import { streamSSE } from "hono/streaming";
|
||||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import { createTransportNode } from "../../node/transport.js";
|
||||
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
|
||||
import type {
|
||||
RemoteConnectRequest,
|
||||
RemoteSendRequest,
|
||||
RemoteSetAuthStateRequest,
|
||||
} from "../types.js";
|
||||
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AuthChallengeError } from "../../../auth/challenge.js";
|
||||
import {
|
||||
DEFAULT_MAX_FETCH_REQUESTS,
|
||||
DEFAULT_TASK_TTL_MS,
|
||||
|
|
@ -52,7 +58,7 @@ import {
|
|||
} from "../../serverList.js";
|
||||
import { resolveImportSource } from "../../import/resolveSource.js";
|
||||
import { RemoteSession } from "./remote-session.js";
|
||||
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
|
||||
import { createRemoteAuthProvider } from "./tokenAuthProvider.js";
|
||||
import { API_SERVER_ENV_VARS } from "../constants.js";
|
||||
import {
|
||||
KeychainUnavailableError,
|
||||
|
|
@ -321,6 +327,20 @@ function forwardLogEvent(
|
|||
}
|
||||
}
|
||||
|
||||
function requestIdForSendWait(
|
||||
message: JSONRPCMessage,
|
||||
): string | number | undefined {
|
||||
if (
|
||||
"method" in message &&
|
||||
"id" in message &&
|
||||
message.id !== null &&
|
||||
message.id !== undefined
|
||||
) {
|
||||
return message.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createRemoteApp(
|
||||
options: RemoteServerOptions,
|
||||
): CreateRemoteAppResult {
|
||||
|
|
@ -525,9 +545,12 @@ export function createRemoteApp(
|
|||
const session = new RemoteSession(sessionId);
|
||||
|
||||
let transport: Awaited<ReturnType<typeof createTransportNode>>["transport"];
|
||||
let authHandle: ReturnType<typeof createRemoteAuthProvider>;
|
||||
try {
|
||||
// Create authProvider from tokens if provided
|
||||
const authProvider = createTokenAuthProvider(body.oauthTokens);
|
||||
const initialAuthState =
|
||||
body.authState ??
|
||||
(body.oauthTokens ? { oauthTokens: body.oauthTokens } : undefined);
|
||||
authHandle = createRemoteAuthProvider(initialAuthState);
|
||||
|
||||
const result = createTransportNode(config, {
|
||||
pipeStderr: true,
|
||||
|
|
@ -535,8 +558,12 @@ export function createRemoteApp(
|
|||
onFetchRequest: (entry) => session.onFetchRequest(entry),
|
||||
onFetchResponseBody: (id, body) =>
|
||||
session.onFetchResponseBody(id, body),
|
||||
authProvider,
|
||||
authProvider: authHandle?.provider,
|
||||
settings: body.settings,
|
||||
// Always intercept 401/403 on the node MCP transport. Without this, the
|
||||
// SDK's built-in auth() retry on a frozen token snapshot can hang or
|
||||
// succeed locally while /api/mcp/send still awaits a JSON-RPC response.
|
||||
interceptAuthChallenges: true,
|
||||
});
|
||||
transport = result.transport;
|
||||
} catch (err) {
|
||||
|
|
@ -544,6 +571,7 @@ export function createRemoteApp(
|
|||
return c.json({ error: `Failed to create transport: ${msg}` }, 500);
|
||||
}
|
||||
|
||||
session.setAuthProviderHandle(authHandle ?? null);
|
||||
session.setTransport(transport);
|
||||
transport.onmessage = (msg) => session.onMessage(msg);
|
||||
|
||||
|
|
@ -558,6 +586,10 @@ export function createRemoteApp(
|
|||
|
||||
// Set up error handlers BEFORE calling start() so we catch failures during start
|
||||
transport.onerror = (err) => {
|
||||
if (session.handleTransportAuthError(err)) {
|
||||
originalOnerror?.(err);
|
||||
return;
|
||||
}
|
||||
transportFailed = true;
|
||||
transportError = err instanceof Error ? err.message : String(err);
|
||||
originalOnerror?.(err);
|
||||
|
|
@ -609,6 +641,19 @@ export function createRemoteApp(
|
|||
}
|
||||
} catch (err) {
|
||||
// transport.start() threw - this is the expected failure path
|
||||
if (err instanceof AuthChallengeError) {
|
||||
try {
|
||||
await transport.close();
|
||||
} catch {
|
||||
// Best-effort cleanup when connect fails with auth challenge.
|
||||
}
|
||||
session.noteAuthChallengeDeliveredViaHttp();
|
||||
return c.json({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: err.authChallenge,
|
||||
});
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Preserve 401 only when the transport/SDK reports it (no message guessing)
|
||||
const status =
|
||||
|
|
@ -648,22 +693,41 @@ export function createRemoteApp(
|
|||
// Check if transport is dead - return error immediately (matches local behavior)
|
||||
if (session.isTransportDead()) {
|
||||
const errorMsg = session.getTransportError() || "Transport closed";
|
||||
return c.json({ error: errorMsg }, 500);
|
||||
return c.json({ ok: false, kind: "transport_error", error: errorMsg });
|
||||
}
|
||||
|
||||
session.beginSend();
|
||||
const requestId = requestIdForSendWait(message);
|
||||
let responseWait: Promise<void> | undefined;
|
||||
if (requestId !== undefined) {
|
||||
responseWait = session.waitForRequestResponse(requestId);
|
||||
// Auth errors may reject the wait via onerror while send() also throws.
|
||||
void responseWait.catch(() => {});
|
||||
}
|
||||
try {
|
||||
await session.transport.send(message, {
|
||||
relatedRequestId: relatedRequestId as string | number | undefined,
|
||||
});
|
||||
if (responseWait) {
|
||||
await responseWait;
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (requestId !== undefined) {
|
||||
session.cancelRequestWait(requestId);
|
||||
}
|
||||
if (err instanceof AuthChallengeError) {
|
||||
session.noteAuthChallengeDeliveredViaHttp();
|
||||
return c.json({
|
||||
ok: false,
|
||||
kind: "auth_challenge",
|
||||
authChallenge: err.authChallenge,
|
||||
});
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Preserve 401 only when the transport/SDK reports it (no message guessing)
|
||||
const status =
|
||||
(err as { code?: number; status?: number }).code ??
|
||||
(err as { code?: number; status?: number }).status;
|
||||
const is401 = status === 401;
|
||||
return c.json({ error: msg }, is401 ? 401 : 500);
|
||||
return c.json({ ok: false, kind: "transport_error", error: msg });
|
||||
} finally {
|
||||
session.endSend();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -746,6 +810,39 @@ export function createRemoteApp(
|
|||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/mcp/auth-state", async (c) => {
|
||||
let body: RemoteSetAuthStateRequest;
|
||||
try {
|
||||
body = (await c.req.json()) as RemoteSetAuthStateRequest;
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const { sessionId, authState } = body;
|
||||
if (!sessionId || !authState) {
|
||||
return c.json({ error: "Missing sessionId or authState" }, 400);
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
if (session.isTransportDead()) {
|
||||
const errorMsg = session.getTransportError() || "Transport closed";
|
||||
return c.json({ ok: false, kind: "transport_error", error: errorMsg });
|
||||
}
|
||||
|
||||
try {
|
||||
session.setAuthState(authState);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: msg }, 400);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/fetch", async (c) => {
|
||||
let body: {
|
||||
url: string;
|
||||
|
|
|
|||
|
|
@ -1,29 +1,48 @@
|
|||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type { RemoteConnectRequest } from "../types.js";
|
||||
import type { RemoteAuthState } 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 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"],
|
||||
): OAuthClientProvider | undefined {
|
||||
if (!tokens) return undefined;
|
||||
export interface RemoteAuthProviderHandle {
|
||||
provider: OAuthClientProvider;
|
||||
setAuthState: (authState: RemoteAuthState) => void;
|
||||
getAuthState: () => RemoteAuthState;
|
||||
}
|
||||
|
||||
function cloneAuthState(state: RemoteAuthState): RemoteAuthState {
|
||||
return {
|
||||
...(state.oauthTokens && {
|
||||
oauthTokens: { ...state.oauthTokens },
|
||||
}),
|
||||
...(state.oauthClient && {
|
||||
oauthClient: { ...state.oauthClient },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable OAuth provider for the remote backend MCP transport.
|
||||
* Browser (or CLI) owns interactive OAuth; this injects Bearer tokens and can
|
||||
* be hot-updated via {@link RemoteAuthProviderHandle.setAuthState}.
|
||||
*/
|
||||
export function createRemoteAuthProvider(
|
||||
initialAuthState?: RemoteAuthState,
|
||||
): RemoteAuthProviderHandle | undefined {
|
||||
if (!initialAuthState?.oauthTokens && !initialAuthState?.oauthClient) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let authState = cloneAuthState(initialAuthState);
|
||||
|
||||
const provider = {
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return REMOTE_OAUTH_STUB_METADATA;
|
||||
},
|
||||
|
|
@ -31,13 +50,31 @@ export function createTokenAuthProvider(
|
|||
return "";
|
||||
},
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return tokens as OAuthTokens;
|
||||
return authState.oauthTokens as OAuthTokens | undefined;
|
||||
},
|
||||
async clientInformation() {
|
||||
return undefined;
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
if (!authState.oauthClient?.client_id) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
client_id: authState.oauthClient.client_id,
|
||||
...(authState.oauthClient.client_secret && {
|
||||
client_secret: authState.oauthClient.client_secret,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async saveTokens() {
|
||||
// No-op
|
||||
async saveTokens(tokens: OAuthTokens) {
|
||||
authState = {
|
||||
...authState,
|
||||
oauthTokens: {
|
||||
access_token: tokens.access_token,
|
||||
token_type: tokens.token_type,
|
||||
expires_in: tokens.expires_in,
|
||||
refresh_token: tokens.refresh_token,
|
||||
scope: tokens.scope,
|
||||
id_token: tokens.id_token,
|
||||
},
|
||||
};
|
||||
},
|
||||
codeVerifier() {
|
||||
return undefined;
|
||||
|
|
@ -46,7 +83,7 @@ export function createTokenAuthProvider(
|
|||
// No-op
|
||||
},
|
||||
clear() {
|
||||
// No-op
|
||||
authState = {};
|
||||
},
|
||||
async redirectToAuthorization() {
|
||||
throw new Error(
|
||||
|
|
@ -57,4 +94,14 @@ export function createTokenAuthProvider(
|
|||
return "";
|
||||
},
|
||||
} as unknown as OAuthClientProvider;
|
||||
|
||||
return {
|
||||
provider,
|
||||
setAuthState(next: RemoteAuthState) {
|
||||
authState = cloneAuthState(next);
|
||||
},
|
||||
getAuthState() {
|
||||
return cloneAuthState(authState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue