inspector/core/auth/ema/emaFlow.ts
cliffhall f4fb4b50c9 Forward RFC 9207 iss to the v2 SDK (fixes OAuth + EMA leg 1)
SDK v2 added RFC 9207 §2.4 authorization-response issuer validation
(`validateAuthorizationResponseIssuer` / `IssuerMismatchError`) as a mix-up
attack defense. It is strict: when AS metadata advertises
`authorization_response_iss_parameter_supported: true` and no `iss` reaches
`exchangeAuthorization`/`auth()`, the exchange throws.

v1.29.0 had no `iss` parameter and no RFC 9207 validation at all, so dropping
the callback `iss` was harmless. The migration swapped in the stricter SDK
without feeding it the new input, breaking the authorization-code exchange
against any RFC 9207 authorization server (Okta/xaa.dev advertise it):

    Issuer mismatch in authorization response (RFC 9207):
    expected "https://idp.xaa.dev", received undefined

Found by smoke-testing EMA against live xaa.dev staging; not caught by CI
because the mock IdP never advertised the parameter, so the SDK took its
lenient path (a missing `iss` is only fatal when advertised).

`iss` was dropped in three places, with the plumbing half-built —
`oauthManager`/`inspectorClient` already accepted `iss`, but nothing supplied
or forwarded it:

- `parseOAuthCallbackParams` never extracted `iss` from the callback query
- `mcpAuth` never forwarded `options.iss` to SDK `auth()` — its own comment
  ("forwarded on v2 upgrade; ignored by v1 SDK auth()") predicted this exact
  gap, so standard OAuth was broken identically, not just EMA
- EMA leg 1 (`completeIdpOidcAuthorization`) never passed `iss` to
  `exchangeAuthorization`

Thread `iss` end to end: callback query -> CallbackParams -> resumeAfterOAuth
-> completeOAuthFlow -> {mcpAuth | EMA leg 1} -> SDK. The Node callback server
and runner carry it for CLI/TUI.

Tests: `ema-mock-servers.ts` now advertises
`authorization_response_iss_parameter_supported: true` so the mock is at least
as strict as real IdPs — with it absent the SDK silently takes the lenient path
and a dropped `iss` goes unnoticed. Flipping it made three existing tests
reproduce the failure. Adds regression coverage for forwarding and for
rejecting a mismatched `iss` before any network call.

Verified end to end against live xaa.dev staging: EMA legs 1-3 (IdP SSO ->
ID-JAG mint -> resource token) now complete and the MCP session initializes.

web 3099, cli 212, tui 274 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjMemXZohSR2EndBQYif2b
2026-07-15 20:16:43 -04:00

176 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { OAuthTokens } from "@modelcontextprotocol/client";
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,
iss?: string,
): Promise<OAuthTokens> {
try {
await completeIdpOidcAuthorization({
idp: config.idp,
authorizationCode,
iss,
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;
}