feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 7585 additions and 595 deletions

View file

@ -1,3 +1,3 @@
export { BrowserOAuthStorage } from "./storage.js";
export { BrowserOAuthStorage, getBrowserOAuthStorage } from "./storage.js";
export { BrowserNavigation, BrowserOAuthClientProvider } from "./providers.js";
export type { OAuthNavigationCallback } from "./providers.js";

View file

@ -3,7 +3,7 @@ import type {
OAuthNavigationCallback,
} from "../providers.js";
import { CallbackNavigation, BaseOAuthClientProvider } from "../providers.js";
import { BrowserOAuthStorage } from "./storage.js";
import { getBrowserOAuthStorage } from "./storage.js";
export type { OAuthNavigationCallback } from "../providers.js";
@ -49,12 +49,12 @@ export class BrowserOAuthClientProvider extends BaseOAuthClientProvider {
"BrowserOAuthClientProvider requires browser environment",
);
}
const storage = new BrowserOAuthStorage();
const storage = getBrowserOAuthStorage();
const redirectUrlProvider: RedirectUrlProvider = {
getRedirectUrl: () => `${window.location.origin}/oauth/callback`,
};
const navigation = new BrowserNavigation();
super(serverUrl, { storage, redirectUrlProvider, navigation }, "normal");
super(serverUrl, { storage, redirectUrlProvider, navigation }, "quick");
}
}

View file

@ -13,3 +13,13 @@ export class BrowserOAuthStorage extends OAuthStorageBase {
super(createOAuthStore(createJSONStorage(() => sessionStorage)));
}
}
let sharedBrowserOAuthStorage: BrowserOAuthStorage | undefined;
/** Shared sessionStorage-backed OAuth store for the web client (single in-memory view). */
export function getBrowserOAuthStorage(): BrowserOAuthStorage {
if (!sharedBrowserOAuthStorage) {
sharedBrowserOAuthStorage = new BrowserOAuthStorage();
}
return sharedBrowserOAuthStorage;
}

View file

@ -0,0 +1,130 @@
import type {
OAuthClientInformation,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
import { getEmaIdpLoginState, normalizeIdpIssuer } from "./ema/idpSession.js";
import { idpOAuthStorageKey } from "./ema/storage.js";
import { isJwtExpired } from "./ema/jwt.js";
import type { OAuthStorage } from "./storage.js";
import type { AuthProtocol, OAuthConnectionState, OAuthFlowState } from "./types.js";
import { authProtocolFromEnterpriseManaged } from "./types.js";
export interface BuildOAuthConnectionStateParams {
serverUrl: string;
protocol: AuthProtocol;
configuredScope?: string;
enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig };
storage: OAuthStorage;
flowState?: OAuthFlowState;
}
function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean {
if (!tokens?.access_token) return false;
return !isJwtExpired(tokens.access_token);
}
function resolveClient(
preregistered: OAuthClientInformation | undefined,
dynamic: OAuthClientInformation | undefined,
): OAuthConnectionState["client"] | undefined {
const info = preregistered ?? dynamic;
if (!info?.client_id) return undefined;
return {
source: preregistered ? "preregistered" : "dynamic",
clientId: info.client_id,
hasClientSecret: info.client_secret !== undefined && info.client_secret !== "",
};
}
function resolveGrantedScope(
tokens: OAuthTokens | undefined,
storageScope: string | undefined,
): string | undefined {
const tokenScope =
typeof tokens?.scope === "string" ? tokens.scope.trim() : undefined;
if (tokenScope) return tokenScope;
const stored =
typeof storageScope === "string" ? storageScope.trim() : undefined;
return stored || undefined;
}
/**
* Assembles persisted OAuth connection state for an HTTP MCP server.
* Reads storage and config only — no network discovery.
*/
export async function buildOAuthConnectionState(
params: BuildOAuthConnectionStateParams,
): Promise<OAuthConnectionState> {
const { serverUrl, storage, flowState } = params;
const protocol = params.protocol;
const storedTokens = await storage.getTokens(serverUrl);
const flowTokens = flowState?.oauthTokens ?? undefined;
const tokens =
flowTokens && isAccessTokenUsable(flowTokens)
? flowTokens
: storedTokens && isAccessTokenUsable(storedTokens)
? storedTokens
: flowTokens ?? storedTokens;
const preregistered = await storage.getClientInformation(serverUrl, true);
const dynamic = await storage.getClientInformation(serverUrl);
const storageScope = storage.getScope(serverUrl);
const serverMetadata = storage.getServerMetadata(serverUrl);
const authorized = isAccessTokenUsable(tokens);
const grantedScope = resolveGrantedScope(tokens, storageScope);
const client = resolveClient(preregistered, dynamic);
const state: OAuthConnectionState = {
authorized,
protocol,
serverUrl,
...(params.configuredScope?.trim() && {
configuredScope: params.configuredScope.trim(),
}),
...(grantedScope && { grantedScope }),
...(tokens && { tokens }),
...(client && { client }),
...(serverMetadata && { authorizationServerMetadata: serverMetadata }),
};
if (protocol === "ema" && params.enterpriseManagedAuth?.idp) {
const idp = params.enterpriseManagedAuth.idp;
const issuer = normalizeIdpIssuer(idp.issuer);
const idpSession = await getEmaIdpLoginState(storage, issuer);
const idpMetadata = storage.getServerMetadata(idpOAuthStorageKey(issuer));
state.ema = {
idpIssuer: issuer,
idpClientId: idp.clientId,
idpSession,
...(idpMetadata && { idpMetadata }),
};
state.enterpriseManaged = true;
}
return state;
}
/** Whether server-level OAuth options are configured on the client. */
export function isServerOAuthConfigured(config: {
clientId?: string;
clientSecret?: string;
scope?: string;
enterpriseManaged?: boolean;
}): boolean {
return (
config.enterpriseManaged === true ||
!!config.clientId?.trim() ||
!!config.clientSecret?.trim() ||
!!config.scope?.trim()
);
}
export function protocolFromOAuthConfig(config: {
enterpriseManaged?: boolean;
}): AuthProtocol {
return authProtocolFromEnterpriseManaged(config.enterpriseManaged);
}

View file

@ -1,5 +1,6 @@
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { parseHttpUrl } from "./utils.js";
/**
* Returns the URL to use for OAuth authorization server metadata discovery.
@ -11,7 +12,15 @@ export function getAuthorizationServerUrl(
): URL {
const first = resourceMetadata?.authorization_servers?.[0];
// Use truthy check to match original state-machine: empty string falls back to serverUrl
return first ? new URL(first) : new URL("/", serverUrl);
if (first) {
return parseHttpUrl(first, "protected resource authorization_servers[0]");
}
try {
return new URL("/", serverUrl);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Invalid MCP server URL: "${serverUrl}" (${detail})`);
}
}
/**

View file

@ -0,0 +1,35 @@
export type EmaClientNotConfiguredReason = "disabled" | "not_configured";
export function emaClientNotConfiguredMessage(
reason: EmaClientNotConfiguredReason,
): string {
if (reason === "disabled") {
return (
"This server uses enterprise-managed authorization, but Enterprise IdP is " +
"turned off in Client Settings. Open Client Settings, enable " +
'"Enterprise IdP configuration", then try connecting again.'
);
}
return (
"This server uses enterprise-managed authorization, but the Inspector " +
"client IdP is not configured. Open Client Settings, enable Enterprise IdP, " +
"and set issuer, client ID, and client secret."
);
}
/** Thrown when connecting to an EMA server without active install-level IdP config. */
export class EmaClientNotConfiguredError extends Error {
readonly reason: EmaClientNotConfiguredReason;
constructor(reason: EmaClientNotConfiguredReason) {
super(emaClientNotConfiguredMessage(reason));
this.name = "EmaClientNotConfiguredError";
this.reason = reason;
}
}
export function isEmaClientNotConfiguredError(
err: unknown,
): err is EmaClientNotConfiguredError {
return err instanceof EmaClientNotConfiguredError;
}

View file

@ -0,0 +1,16 @@
/** RFC 8693 token exchange grant. */
export const GRANT_TYPE_TOKEN_EXCHANGE =
"urn:ietf:params:oauth:grant-type:token-exchange";
/** RFC 7523 JWT bearer grant. */
export const GRANT_TYPE_JWT_BEARER =
"urn:ietf:params:oauth:grant-type:jwt-bearer";
/** ID Token as subject_token (OIDC). */
export const TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token";
/** Identity Assertion JWT Authorization Grant (ID-JAG). */
export const TOKEN_TYPE_ID_JAG = "urn:ietf:params:oauth:token-type:id-jag";
/** OIDC scopes for IdP login (leg 1). */
export const IDP_OIDC_SCOPES = "openid offline_access";

174
core/auth/ema/emaFlow.ts Normal file
View file

@ -0,0 +1,174 @@
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthStorage } from "../storage.js";
import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js";
import {
completeIdpOidcAuthorization,
getValidIdToken,
startIdpOidcAuthorization,
} from "./idpOidc.js";
import {
discoverEmaResourceContext,
type EmaResourceContext,
} from "./resourceContext.js";
import { exchangeIdJag, redeemIdJagForAccessToken } from "./wire.js";
export interface EmaFlowConfig {
serverUrl: string;
idp: EnterpriseManagedAuthIdpConfig;
resourceClientId?: string;
resourceClientSecret?: string;
scope?: string;
redirectUrl: string;
storage: OAuthStorage;
fetchFn?: typeof fetch;
}
export type TrySilentEmaAuthResult =
| { status: "success" }
| { status: "no_idp_session" }
| { status: "mint_failed"; error: Error };
function wrapEmaMintError(err: unknown): Error {
const message = err instanceof Error ? err.message : String(err);
return new Error(`EMA legs 2–3 (resource token mint): ${message}`, {
cause: err,
});
}
export async function mintEmaResourceTokens(
config: EmaFlowConfig,
resourceContext?: EmaResourceContext,
): Promise<OAuthTokens> {
const ctx =
resourceContext ??
(await discoverEmaResourceContext(
config.serverUrl,
config.scope,
config.fetchFn,
));
if (!config.resourceClientId) {
throw new Error(
"EMA requires resource authorization server clientId (per-server oauth.clientId)",
);
}
if (!config.resourceClientSecret?.trim()) {
throw new Error(
"EMA requires resource authorization server client secret in server OAuth settings (Test Client secret from xaa.dev resource registration)",
);
}
const idToken = await getValidIdToken({
idp: config.idp,
storage: config.storage,
fetchFn: config.fetchFn,
});
if (!idToken) {
throw new Error("Valid IdP ID Token required for EMA token mint");
}
const audience = ctx.resourceAsUrl.href.replace(/\/$/, "");
const idJag = await exchangeIdJag({
idp: config.idp,
idToken,
audience,
resource: ctx.resourceUrl?.href,
scope: ctx.scope,
fetchFn: config.fetchFn,
});
return redeemIdJagForAccessToken({
resourceAsUrl: ctx.resourceAsUrl,
idJag,
resourceClientId: config.resourceClientId,
resourceClientSecret: config.resourceClientSecret,
resource: ctx.resourceUrl?.href,
scope: ctx.scope,
fetchFn: config.fetchFn,
});
}
/** Silent path: cached IdP session + legs 2–3. */
export async function trySilentEmaAuth(
config: EmaFlowConfig,
): Promise<TrySilentEmaAuthResult> {
const idToken = await getValidIdToken({
idp: config.idp,
storage: config.storage,
fetchFn: config.fetchFn,
});
if (!idToken) {
return { status: "no_idp_session" };
}
try {
const tokens = await mintEmaResourceTokens(config);
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return { status: "success" };
} catch (err) {
return { status: "mint_failed", error: wrapEmaMintError(err) };
}
}
export async function startEmaIdpAuthorization(
config: EmaFlowConfig,
): Promise<URL> {
const { authorizationUrl } = await startIdpOidcAuthorization({
idp: config.idp,
redirectUrl: config.redirectUrl,
storage: config.storage,
fetchFn: config.fetchFn,
});
return authorizationUrl;
}
export async function completeEmaIdpAuthorizationAndMint(
config: EmaFlowConfig,
authorizationCode: string,
): Promise<OAuthTokens> {
try {
await completeIdpOidcAuthorization({
idp: config.idp,
authorizationCode,
redirectUrl: config.redirectUrl,
storage: config.storage,
fetchFn: config.fetchFn,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`EMA leg 1 (IdP authorization code exchange): ${message}`, {
cause: err,
});
}
let tokens: OAuthTokens;
try {
tokens = await mintEmaResourceTokens(config);
} catch (err) {
throw wrapEmaMintError(err);
}
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return tokens;
}
/** Re-run legs 2–3 on 401 when IdP session is still valid. */
export async function refreshEmaResourceTokens(
config: EmaFlowConfig,
): Promise<OAuthTokens | undefined> {
const idToken = await getValidIdToken({
idp: config.idp,
storage: config.storage,
fetchFn: config.fetchFn,
});
if (!idToken) {
return undefined;
}
const tokens = await mintEmaResourceTokens(config);
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return tokens;
}

235
core/auth/ema/idpOidc.ts Normal file
View file

@ -0,0 +1,235 @@
import {
discoverAuthorizationServerMetadata,
exchangeAuthorization,
startAuthorization,
} from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformation,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthStorage } from "../storage.js";
import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js";
import { generateOAuthStateWithExecution, parseHttpUrl } from "../utils.js";
import { IDP_OIDC_SCOPES } from "./constants.js";
import { isJwtExpired, jwtExpiresAtMs } from "./jwt.js";
import { idpOAuthStorageKey, normalizeIdpIssuer } from "./storage.js";
import {
parseOAuthTokenErrorResponse,
postOAuthTokenRequest,
} from "./tokenEndpoint.js";
function idpClientInformation(
idp: EnterpriseManagedAuthIdpConfig,
): OAuthClientInformation {
return {
client_id: idp.clientId,
client_secret: idp.clientSecret,
token_endpoint_auth_method: "client_secret_post",
} as OAuthClientInformation;
}
async function resolveIdpMetadata(
issuer: string,
storage: OAuthStorage,
fetchFn?: typeof fetch,
): Promise<OAuthMetadata> {
const storageKey = idpOAuthStorageKey(issuer);
const cached = storage.getServerMetadata(storageKey);
if (cached?.token_endpoint) {
return cached;
}
return discoverIdpMetadata(issuer, fetchFn);
}
export async function discoverIdpMetadata(
issuer: string,
fetchFn?: typeof fetch,
): Promise<OAuthMetadata> {
const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)");
const metadata = await discoverAuthorizationServerMetadata(issuerUrl, {
fetchFn,
});
if (!metadata) {
throw new Error(`Failed to discover OIDC metadata for IdP issuer ${issuer}`);
}
return OAuthMetadataSchema.parse(metadata);
}
export async function startIdpOidcAuthorization(params: {
idp: EnterpriseManagedAuthIdpConfig;
redirectUrl: string;
storage: OAuthStorage;
fetchFn?: typeof fetch;
}): Promise<{ authorizationUrl: URL }> {
const issuer = normalizeIdpIssuer(params.idp.issuer);
const metadata = await discoverIdpMetadata(issuer, params.fetchFn);
const clientInformation = idpClientInformation(params.idp);
const storageKey = idpOAuthStorageKey(issuer);
const state = generateOAuthStateWithExecution("quick");
const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)");
const { authorizationUrl, codeVerifier } = await startAuthorization(
issuerUrl,
{
metadata,
clientInformation,
redirectUrl: params.redirectUrl,
scope: IDP_OIDC_SCOPES,
state,
},
);
await params.storage.saveCodeVerifier(storageKey, codeVerifier);
await params.storage.savePreregisteredClientInformation(
storageKey,
clientInformation,
);
// Stash IdP metadata for token exchange on callback.
await params.storage.saveServerMetadata(storageKey, metadata);
return { authorizationUrl };
}
export async function completeIdpOidcAuthorization(params: {
idp: EnterpriseManagedAuthIdpConfig;
authorizationCode: string;
redirectUrl: string;
storage: OAuthStorage;
fetchFn?: typeof fetch;
}): Promise<{
idToken: string;
refreshToken?: string;
idTokenExpiresAt?: number;
}> {
const issuer = normalizeIdpIssuer(params.idp.issuer);
const storageKey = idpOAuthStorageKey(issuer);
const metadata = params.storage.getServerMetadata(storageKey);
if (!metadata) {
throw new Error("IdP OAuth metadata not found — restart EMA IdP login");
}
const clientInformation =
(await params.storage.getClientInformation(storageKey, true)) ??
(await params.storage.getClientInformation(storageKey));
if (!clientInformation) {
throw new Error("IdP client information not found — restart EMA IdP login");
}
const codeVerifier = params.storage.getCodeVerifier(storageKey);
if (!codeVerifier) {
throw new Error("IdP PKCE verifier not found — restart EMA IdP login");
}
const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)");
const tokens = await exchangeAuthorization(issuerUrl, {
metadata,
clientInformation,
authorizationCode: params.authorizationCode,
codeVerifier,
redirectUri: params.redirectUrl,
fetchFn: params.fetchFn,
});
const idToken = tokens.id_token;
if (!idToken) {
throw new Error("IdP token response did not include an ID Token");
}
params.storage.clearCodeVerifier(storageKey);
const idTokenExpiresAt = jwtExpiresAtMs(idToken);
await params.storage.saveIdpSession(issuer, {
idToken,
refreshToken: tokens.refresh_token,
idTokenExpiresAt,
});
return {
idToken,
refreshToken: tokens.refresh_token,
idTokenExpiresAt,
};
}
/** Redeem a cached IdP refresh token for a new ID Token (OIDC refresh grant). */
export async function refreshIdpOidcSession(params: {
idp: EnterpriseManagedAuthIdpConfig;
storage: OAuthStorage;
fetchFn?: typeof fetch;
}): Promise<string> {
const issuer = normalizeIdpIssuer(params.idp.issuer);
const session = await params.storage.getIdpSession(issuer);
if (!session?.refreshToken) {
throw new Error("IdP refresh token not available");
}
const metadata = await resolveIdpMetadata(
issuer,
params.storage,
params.fetchFn,
);
if (!metadata.token_endpoint) {
throw new Error("IdP metadata missing token_endpoint");
}
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: session.refreshToken,
});
const response = await postOAuthTokenRequest(
parseHttpUrl(
metadata.token_endpoint,
"IdP token_endpoint (from OIDC discovery)",
),
body,
metadata,
idpClientInformation(params.idp),
params.fetchFn,
);
if (!response.ok) {
throw await parseOAuthTokenErrorResponse(
response,
"EMA IdP refresh (OIDC refresh_token grant)",
);
}
const json = (await response.json()) as {
id_token?: string;
refresh_token?: string;
};
const idToken = json.id_token;
if (!idToken) {
throw new Error("IdP refresh did not return an ID Token");
}
await params.storage.saveIdpSession(issuer, {
idToken,
refreshToken: json.refresh_token ?? session.refreshToken,
idTokenExpiresAt: jwtExpiresAtMs(idToken),
});
return idToken;
}
/**
* Returns a non-expired IdP ID Token from storage, refreshing via refresh_token
* when the cached ID Token has expired but a refresh token remains.
*/
export async function getValidIdToken(params: {
idp: EnterpriseManagedAuthIdpConfig;
storage: OAuthStorage;
fetchFn?: typeof fetch;
}): Promise<string | undefined> {
const issuer = normalizeIdpIssuer(params.idp.issuer);
const session = await params.storage.getIdpSession(issuer);
if (!session?.idToken) {
return undefined;
}
if (!isJwtExpired(session.idToken)) {
return session.idToken;
}
if (!session.refreshToken) {
return undefined;
}
try {
return await refreshIdpOidcSession(params);
} catch {
return undefined;
}
}

View file

@ -0,0 +1,30 @@
import type { OAuthStorage } from "../storage.js";
import { isJwtExpired } from "./jwt.js";
import { idpOAuthStorageKey, normalizeIdpIssuer } from "./storage.js";
export type EmaIdpLoginState = "none" | "logged_in" | "expired";
export { normalizeIdpIssuer };
/** Whether a cached IdP OIDC session exists for the configured issuer. */
export async function getEmaIdpLoginState(
storage: OAuthStorage,
issuer: string,
): Promise<EmaIdpLoginState> {
const normalized = normalizeIdpIssuer(issuer);
if (!normalized) return "none";
const session = await storage.getIdpSession(normalized);
if (!session?.idToken) return "none";
if (!isJwtExpired(session.idToken)) return "logged_in";
if (session.refreshToken) return "logged_in";
return "expired";
}
export function clearEmaIdpSession(storage: OAuthStorage, issuer: string): void {
const normalized = normalizeIdpIssuer(issuer);
if (!normalized) return;
storage.clearIdpSession(normalized);
storage.clear(idpOAuthStorageKey(normalized));
storage.clearEnterpriseManagedResourceServers();
}

31
core/auth/ema/index.ts Normal file
View file

@ -0,0 +1,31 @@
export {
GRANT_TYPE_JWT_BEARER,
GRANT_TYPE_TOKEN_EXCHANGE,
IDP_OIDC_SCOPES,
TOKEN_TYPE_ID_JAG,
TOKEN_TYPE_ID_TOKEN,
} from "./constants.js";
export {
completeEmaIdpAuthorizationAndMint,
mintEmaResourceTokens,
refreshEmaResourceTokens,
startEmaIdpAuthorization,
trySilentEmaAuth,
type EmaFlowConfig,
type TrySilentEmaAuthResult,
} from "./emaFlow.js";
export { EmaTransportOAuthProvider } from "./transportProvider.js";
export { isJwtExpired, jwtExpiresAtMs } from "./jwt.js";
export {
clearEmaIdpSession,
getEmaIdpLoginState,
normalizeIdpIssuer,
type EmaIdpLoginState,
} from "./idpSession.js";
export { discoverEmaResourceContext, resolveEmaScopes } from "./resourceContext.js";
export {
EmaClientNotConfiguredError,
emaClientNotConfiguredMessage,
isEmaClientNotConfiguredError,
type EmaClientNotConfiguredReason,
} from "./clientConfigError.js";

70
core/auth/ema/jwt.ts Normal file
View file

@ -0,0 +1,70 @@
function decodeBase64Url(segment: string): string {
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"=",
);
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
/** Best-effort `exp` (epoch seconds) from an unparsed JWT payload. */
export function jwtExpiresAtMs(token: string): number | undefined {
const parts = token.split(".");
if (parts.length < 2) return undefined;
try {
const payload = JSON.parse(decodeBase64Url(parts[1]!)) as { exp?: number };
if (typeof payload.exp === "number" && Number.isFinite(payload.exp)) {
return payload.exp * 1000;
}
} catch {
return undefined;
}
return undefined;
}
export function isJwtExpired(
token: string,
skewMs = 60_000,
nowMs = Date.now(),
): boolean {
const exp = jwtExpiresAtMs(token);
if (exp === undefined) return false;
return nowMs >= exp - skewMs;
}
/** True when `token` has the three-segment JWT shape (no signature verification). */
export function isJwtFormat(token: string): boolean {
const parts = token.split(".");
return (
parts.length === 3 &&
parts[0]!.length > 0 &&
parts[1]!.length > 0 &&
parts[2] !== undefined
);
}
/** Best-effort decode of JWT header and payload for display (no verification). */
export function decodeJwtPayload(token: string):
| {
header: Record<string, unknown>;
payload: Record<string, unknown>;
}
| undefined {
if (!isJwtFormat(token)) return undefined;
const parts = token.split(".");
try {
const header = JSON.parse(decodeBase64Url(parts[0]!)) as Record<
string,
unknown
>;
const payload = JSON.parse(decodeBase64Url(parts[1]!)) as Record<
string,
unknown
>;
return { header, payload };
} catch {
return undefined;
}
}

View file

@ -0,0 +1,76 @@
import {
discoverAuthorizationServerMetadata,
discoverOAuthProtectedResourceMetadata,
selectResourceURL,
} from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { getAuthorizationServerUrl } from "../discovery.js";
export interface EmaResourceContext {
resourceMetadata: OAuthProtectedResourceMetadata;
resourceAsUrl: URL;
resourceUrl?: URL;
scope?: string;
}
export function resolveEmaScopes(
resourceMetadata: OAuthProtectedResourceMetadata | null | undefined,
configuredScope?: string,
): string | undefined {
const trimmed = configuredScope?.trim();
if (trimmed) return trimmed;
const supported = resourceMetadata?.scopes_supported;
if (supported && supported.length > 0) {
return supported.join(" ");
}
return undefined;
}
export async function discoverEmaResourceContext(
serverUrl: string,
configuredScope?: string,
fetchFn?: typeof fetch,
): Promise<EmaResourceContext> {
const resourceMetadata = await discoverOAuthProtectedResourceMetadata(
serverUrl,
undefined,
fetchFn,
);
if (!resourceMetadata.resource) {
throw new Error(
"EMA requires protected resource metadata with a resource identifier",
);
}
const resourceAsUrl = getAuthorizationServerUrl(serverUrl, resourceMetadata);
const resourceUrl = await selectResourceURL(
serverUrl,
{
clientMetadata: {
scope: configuredScope ?? "",
redirect_uris: [],
},
} as unknown as OAuthClientProvider,
resourceMetadata,
);
const scope = resolveEmaScopes(resourceMetadata, configuredScope);
return {
resourceMetadata,
resourceAsUrl,
resourceUrl: resourceUrl ?? undefined,
scope,
};
}
export async function discoverResourceAsMetadata(
resourceAsUrl: URL,
fetchFn?: typeof fetch,
) {
const metadata = await discoverAuthorizationServerMetadata(resourceAsUrl, {
fetchFn,
});
if (!metadata) {
throw new Error("Failed to discover resource authorization server metadata");
}
return metadata;
}

9
core/auth/ema/storage.ts Normal file
View file

@ -0,0 +1,9 @@
/** Strip trailing slash so issuer URLs match across discovery, storage keys, and sessions. */
export function normalizeIdpIssuer(issuer: string): string {
return issuer.replace(/\/$/, "");
}
/** OAuth storage key for in-flight IdP OIDC (PKCE, metadata). Not an OAuth `state` param prefix. */
export function idpOAuthStorageKey(issuer: string): string {
return `ema-idp:${normalizeIdpIssuer(issuer)}`;
}

View file

@ -0,0 +1,70 @@
import { selectClientAuthMethod } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformation,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
export async function parseOAuthTokenErrorResponse(
response: Response,
step: string,
): Promise<Error> {
let body: unknown;
try {
body = await response.json();
} catch {
return new Error(`${step}: token request failed (HTTP ${response.status})`);
}
if (typeof body === "object" && body !== null) {
const record = body as { error?: string; error_description?: string };
const parts = [step];
if (record.error) parts.push(`error=${record.error}`);
if (record.error_description) parts.push(record.error_description);
if (parts.length > 1) {
return new Error(parts.join(": "));
}
}
return new Error(`${step}: token request failed (HTTP ${response.status})`);
}
function applyClientAuth(
metadata: OAuthMetadata | undefined,
clientInformation: OAuthClientInformation,
headers: Headers,
body: URLSearchParams,
): void {
const supported = metadata?.token_endpoint_auth_methods_supported ?? [];
const method = selectClientAuthMethod(clientInformation, supported);
if (method === "client_secret_basic" && clientInformation.client_secret) {
const credentials = btoa(
`${clientInformation.client_id}:${clientInformation.client_secret}`,
);
headers.set("Authorization", `Basic ${credentials}`);
return;
}
if (method === "client_secret_post" && clientInformation.client_secret) {
body.set("client_id", clientInformation.client_id);
body.set("client_secret", clientInformation.client_secret);
return;
}
body.set("client_id", clientInformation.client_id);
}
/** POST application/x-www-form-urlencoded to an OAuth token endpoint. */
export async function postOAuthTokenRequest(
tokenUrl: URL,
body: URLSearchParams,
metadata: OAuthMetadata | undefined,
clientInformation: OAuthClientInformation,
fetchFn?: typeof fetch,
): Promise<Response> {
const headers = new Headers({
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
});
applyClientAuth(metadata, clientInformation, headers, body);
return (fetchFn ?? fetch)(tokenUrl, {
method: "POST",
headers,
body,
});
}

View file

@ -0,0 +1,103 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { BaseOAuthClientProvider } from "../providers.js";
import {
refreshEmaResourceTokens,
startEmaIdpAuthorization,
type EmaFlowConfig,
} from "./emaFlow.js";
import { isJwtExpired } from "./jwt.js";
function isAccessTokenUsable(
tokens: OAuthTokens,
skewMs = 60_000,
): boolean {
if (!tokens.access_token) return false;
return !isJwtExpired(tokens.access_token, skewMs);
}
/**
* OAuth provider for MCP transport when enterprise-managed authorization is enabled.
*
* Proactively refreshes resource tokens (EMA legs 2–3) via {@link tokens} before
* requests. On 401 when the IdP session is still valid, the SDK {@link auth}
* call may still reach {@link redirectToAuthorization}; we ignore the resource-AS
* URL and redirect to the IdP instead.
*/
export class EmaTransportOAuthProvider implements OAuthClientProvider {
private readonly inner: BaseOAuthClientProvider;
private readonly emaConfig: EmaFlowConfig;
constructor(
inner: BaseOAuthClientProvider,
emaConfig: EmaFlowConfig,
) {
this.inner = inner;
this.emaConfig = emaConfig;
}
get redirectUrl(): string {
return this.inner.redirectUrl;
}
get clientMetadataUrl(): string | undefined {
return this.inner.clientMetadataUrl;
}
get clientMetadata(): OAuthClientMetadata {
return this.inner.clientMetadata;
}
state(): string | Promise<string> {
return this.inner.state();
}
clientInformation():
| OAuthClientInformationMixed
| undefined
| Promise<OAuthClientInformationMixed | undefined> {
return this.inner.clientInformation();
}
saveClientInformation(
clientInformation: OAuthClientInformationMixed,
): void | Promise<void> {
return this.inner.saveClientInformation(clientInformation);
}
async tokens(): Promise<OAuthTokens | undefined> {
const stored = await this.inner.tokens();
if (stored && isAccessTokenUsable(stored)) {
return stored;
}
const refreshed = await refreshEmaResourceTokens(this.emaConfig);
if (refreshed) {
return refreshed;
}
return undefined;
}
saveTokens(tokens: OAuthTokens): void | Promise<void> {
return this.emaConfig.storage.saveTokens(this.emaConfig.serverUrl, tokens, {
enterpriseManaged: true,
});
}
async redirectToAuthorization(_authorizationUrl: URL): Promise<void> {
const idpAuthorizationUrl = await startEmaIdpAuthorization(this.emaConfig);
this.inner.clearCapturedAuthUrl();
this.inner.redirectToAuthorization(idpAuthorizationUrl);
}
saveCodeVerifier(codeVerifier: string): void | Promise<void> {
return this.inner.saveCodeVerifier(codeVerifier);
}
codeVerifier(): string | Promise<string> {
return this.inner.codeVerifier();
}
}

145
core/auth/ema/wire.ts Normal file
View file

@ -0,0 +1,145 @@
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformation,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js";
import {
GRANT_TYPE_JWT_BEARER,
GRANT_TYPE_TOKEN_EXCHANGE,
TOKEN_TYPE_ID_JAG,
TOKEN_TYPE_ID_TOKEN,
} from "./constants.js";
import { parseHttpUrl } from "../utils.js";
import { discoverResourceAsMetadata } from "./resourceContext.js";
import { normalizeIdpIssuer } from "./storage.js";
import {
parseOAuthTokenErrorResponse,
postOAuthTokenRequest,
} from "./tokenEndpoint.js";
/** Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693). */
export async function exchangeIdJag(params: {
idp: EnterpriseManagedAuthIdpConfig;
idToken: string;
audience: string;
resource?: string;
scope?: string;
fetchFn?: typeof fetch;
}): Promise<string> {
const issuer = normalizeIdpIssuer(params.idp.issuer);
const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)");
const idpMetadata = await discoverAuthorizationServerMetadata(issuerUrl, {
fetchFn: params.fetchFn,
});
if (!idpMetadata?.token_endpoint) {
throw new Error("IdP metadata missing token_endpoint");
}
const clientInformation = {
client_id: params.idp.clientId,
client_secret: params.idp.clientSecret,
token_endpoint_auth_method: "client_secret_post",
} as OAuthClientInformation;
const body = new URLSearchParams({
grant_type: GRANT_TYPE_TOKEN_EXCHANGE,
requested_token_type: TOKEN_TYPE_ID_JAG,
subject_token: params.idToken,
subject_token_type: TOKEN_TYPE_ID_TOKEN,
audience: params.audience,
});
if (params.resource) {
body.set("resource", params.resource);
}
if (params.scope) {
body.set("scope", params.scope);
}
const response = await postOAuthTokenRequest(
parseHttpUrl(
idpMetadata.token_endpoint,
"IdP token_endpoint (from OIDC discovery)",
),
body,
idpMetadata,
clientInformation,
params.fetchFn,
);
if (!response.ok) {
throw await parseOAuthTokenErrorResponse(
response,
"EMA leg 2 (IdP token exchange for ID-JAG)",
);
}
const json = (await response.json()) as {
access_token?: string;
issued_token_type?: string;
};
const idJag = json.access_token;
if (!idJag) {
throw new Error("IdP token exchange did not return an ID-JAG");
}
return idJag;
}
/** Leg 3 — redeem ID-JAG for MCP resource access token (RFC 7523). */
export async function redeemIdJagForAccessToken(params: {
resourceAsUrl: URL;
idJag: string;
resourceClientId: string;
resourceClientSecret?: string;
resource?: string;
scope?: string;
fetchFn?: typeof fetch;
}): Promise<OAuthTokens> {
const asMetadata = await discoverResourceAsMetadata(
params.resourceAsUrl,
params.fetchFn,
);
if (!asMetadata.token_endpoint) {
throw new Error("Resource AS metadata missing token_endpoint");
}
const clientInformation = {
client_id: params.resourceClientId,
...(params.resourceClientSecret && {
client_secret: params.resourceClientSecret,
}),
token_endpoint_auth_method: params.resourceClientSecret
? "client_secret_post"
: "none",
} as OAuthClientInformation;
const body = new URLSearchParams({
grant_type: GRANT_TYPE_JWT_BEARER,
assertion: params.idJag,
});
if (params.scope) {
body.set("scope", params.scope);
}
if (params.resource) {
body.set("resource", params.resource);
}
const response = await postOAuthTokenRequest(
parseHttpUrl(
asMetadata.token_endpoint,
"resource authorization server token_endpoint (from AS discovery)",
),
body,
asMetadata,
clientInformation,
params.fetchFn,
);
if (!response.ok) {
throw await parseOAuthTokenErrorResponse(
response,
"EMA leg 3 (resource AS JWT bearer grant)",
);
}
return OAuthTokensSchema.parse(await response.json());
}

View file

@ -1,16 +1,28 @@
// Types
export type {
OAuthStep,
OAuthAuthType,
AuthExecution,
AuthProtocol,
MessageType,
StatusMessage,
AuthGuidedState,
OAuthFlowState,
OAuthConnectionState,
CallbackParams,
} from "./types.js";
export { EMPTY_GUIDED_STATE } from "./types.js";
export {
EMPTY_OAUTH_FLOW_STATE,
authProtocolFromEnterpriseManaged,
} from "./types.js";
export {
buildOAuthConnectionState,
isServerOAuthConfigured,
protocolFromOAuthConfig,
} from "./connection-state.js";
export type { BuildOAuthConnectionStateParams } from "./connection-state.js";
// Storage
export type { OAuthStorage } from "./storage.js";
export type { OAuthStorage, IdpSessionState } from "./storage.js";
export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js";
// Providers
@ -29,13 +41,14 @@ export {
// Utilities
export {
parseHttpUrl,
parseOAuthCallbackParams,
generateOAuthState,
generateOAuthStateWithExecution,
generateOAuthStateWithMode,
parseOAuthState,
generateOAuthErrorDescription,
} from "./utils.js";
export type { OAuthStateMode } from "./utils.js";
// Discovery
export { discoverScopes } from "./discovery.js";

View file

@ -52,7 +52,7 @@ export interface OAuthCallbackServerStartResult {
/**
* Minimal HTTP server that receives OAuth 2.1 redirects at GET /oauth/callback.
* Used by TUI/CLI to complete the authorization code flow (both normal and guided).
* Used by TUI/CLI to complete the authorization code flow (both quick and guided).
* Caller provides onCallback/onError; typically onCallback calls
* InspectorClient.completeOAuthFlow(code) then stops the server.
*/

View file

@ -22,6 +22,7 @@ const SERVICE_NAME = "mcp-inspector";
export {
SECRET_FIELD_OAUTH_CLIENT_SECRET,
SECRET_FIELD_IDP_CLIENT_SECRET,
envSecretField,
} from "../secret-fields.js";

Some files were not shown because too many files have changed in this diff Show more