All of the mid-session and step-up auth work

This commit is contained in:
Bob Dickinson 2026-07-02 16:35:31 -07:00
commit 562bdd00f1
10 changed files with 11437 additions and 1212 deletions

331
core/auth/challenge.ts Normal file
View 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;
}

View file

@ -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);
}

View file

@ -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
View 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";
}

View file

@ -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";

View 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
View 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;
}

View file

@ -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
View 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;
}

View file

@ -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