Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
parent
5ee347571f
commit
5de4e911e3
28 changed files with 7585 additions and 595 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
130
core/auth/connection-state.ts
Normal file
130
core/auth/connection-state.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
35
core/auth/ema/clientConfigError.ts
Normal file
35
core/auth/ema/clientConfigError.ts
Normal 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;
|
||||
}
|
||||
16
core/auth/ema/constants.ts
Normal file
16
core/auth/ema/constants.ts
Normal 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
174
core/auth/ema/emaFlow.ts
Normal 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
235
core/auth/ema/idpOidc.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
core/auth/ema/idpSession.ts
Normal file
30
core/auth/ema/idpSession.ts
Normal 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
31
core/auth/ema/index.ts
Normal 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
70
core/auth/ema/jwt.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
core/auth/ema/resourceContext.ts
Normal file
76
core/auth/ema/resourceContext.ts
Normal 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
9
core/auth/ema/storage.ts
Normal 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)}`;
|
||||
}
|
||||
70
core/auth/ema/tokenEndpoint.ts
Normal file
70
core/auth/ema/tokenEndpoint.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
103
core/auth/ema/transportProvider.ts
Normal file
103
core/auth/ema/transportProvider.ts
Normal 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
145
core/auth/ema/wire.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue