inspector/core/auth/node/oauth-callback-server.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

226 lines
6.5 KiB
TypeScript

import { createServer, type Server } from "node:http";
import { parseOAuthCallbackParams } from "../utils.js";
import { generateOAuthErrorDescription } from "../utils.js";
const DEFAULT_HOSTNAME = "127.0.0.1";
const DEFAULT_CALLBACK_PATH = "/oauth/callback";
const SUCCESS_HTML = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>OAuth complete</title></head>
<body><p>OAuth complete. You can close this window.</p></body>
</html>`;
function errorHtml(message: string): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>OAuth error</title></head>
<body><p>OAuth failed: ${escapeHtml(message)}</p></body>
</html>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export type OAuthCallbackHandler = (params: {
code: string;
state?: string;
/** RFC 9207 `iss`, when the authorization server sends it. */
iss?: string;
}) => Promise<void>;
export type OAuthErrorHandler = (params: {
error: string;
error_description?: string | null;
}) => void;
export interface OAuthCallbackServerStartOptions {
port?: number;
hostname?: string;
path?: string;
onCallback?: OAuthCallbackHandler;
onError?: OAuthErrorHandler;
}
export interface OAuthCallbackServerStartResult {
port: number;
redirectUrl: string;
}
/**
* Minimal HTTP server that receives OAuth 2.1 redirects at GET /oauth/callback.
* Used by TUI/CLI to complete the authorization code flow.
* Caller provides onCallback/onError; typically onCallback calls
* InspectorClient.completeOAuthFlow(code) then stops the server.
*/
export class OAuthCallbackServer {
private server: Server | null = null;
private port: number = 0;
private callbackPath: string = DEFAULT_CALLBACK_PATH;
private handled = false;
private onCallback?: OAuthCallbackHandler;
private onError?: OAuthErrorHandler;
/**
* Start the server. Port defaults to 0 (random) when omitted; TUI/CLI callers
* pass the resolved port from {@link parseRunnerOAuthCallbackUrl} (6276 by default).
* Returns port and redirectUrl for use as oauth.redirectUrl.
*/
async start(
options: OAuthCallbackServerStartOptions = {},
): Promise<OAuthCallbackServerStartResult> {
const {
port = 0,
hostname = DEFAULT_HOSTNAME,
path = DEFAULT_CALLBACK_PATH,
onCallback,
onError,
} = options;
if (!path.startsWith("/")) {
return Promise.reject(
new Error("Callback path must start with '/' (absolute path)"),
);
}
this.onCallback = onCallback;
this.onError = onError;
this.handled = false;
this.callbackPath = path;
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => this.handleRequest(req, res));
this.server.on("error", reject);
this.server.listen(port, hostname, () => {
const a = this.server!.address();
/* v8 ignore next 4 -- defensive: a TCP server that has fired its
`listen` callback always reports an AddressInfo object (never null
and never a string, which only happens for a pipe/unix socket),
so this guard is unreachable from a successful `listen(port, host)`. */
if (!a || typeof a === "string") {
reject(new Error("Failed to get server address"));
return;
}
this.port = a.port;
resolve({
port: this.port,
redirectUrl: buildRedirectUrl(hostname, this.port, path),
});
});
});
}
/**
* Stop the server. Idempotent.
*/
async stop(): Promise<void> {
if (!this.server) return;
await new Promise<void>((resolve) => {
this.server!.close(() => resolve());
});
this.server = null;
}
private handleRequest(
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse<
import("node:http").IncomingMessage
>,
): void {
const needJson = req.headers["accept"]?.includes("application/json");
const send = (
status: number,
body: string,
contentType = "text/html; charset=utf-8",
) => {
res.writeHead(status, { "Content-Type": contentType });
res.end(body);
};
if (req.method !== "GET") {
send(405, needJson ? '{"error":"Method Not Allowed"}' : SUCCESS_HTML);
return;
}
let pathname: string;
let search: string;
let state: string | undefined;
try {
const u = new URL(
/* v8 ignore next -- defensive: Node always populates req.url for an
HTTP request, so the `?? ""` fallback is never taken. */
req.url ?? "",
"http://placeholder",
);
pathname = u.pathname;
search = u.search;
state = u.searchParams.get("state") ?? undefined;
} catch {
send(400, needJson ? '{"error":"Bad Request"}' : SUCCESS_HTML);
return;
}
if (pathname !== this.callbackPath) {
send(404, needJson ? '{"error":"Not Found"}' : SUCCESS_HTML);
return;
}
if (this.handled) {
send(
409,
needJson ? '{"error":"Callback already handled"}' : SUCCESS_HTML,
);
return;
}
const params = parseOAuthCallbackParams(search);
if (params.successful) {
this.handled = true;
const cb = this.onCallback;
if (cb) {
cb({ code: params.code, state, iss: params.iss })
.then(() => {
send(200, SUCCESS_HTML);
void this.stop();
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
this.onError?.({ error: "callback_error", error_description: msg });
send(500, errorHtml(msg));
void this.stop();
});
} else {
send(200, SUCCESS_HTML);
void this.stop();
}
return;
}
this.handled = true;
const msg = generateOAuthErrorDescription(params);
this.onError?.({
error: params.error,
error_description: params.error_description ?? undefined,
});
send(400, errorHtml(msg));
}
}
/**
* Create an OAuth callback server instance.
* Use start() then stop() when the OAuth flow is done.
*/
export function createOAuthCallbackServer(): OAuthCallbackServer {
return new OAuthCallbackServer();
}
function buildRedirectUrl(host: string, port: number, path: string): string {
const needsBrackets = host.includes(":") && !host.startsWith("[");
const formattedHost = needsBrackets ? `[${host}]` : host;
return `http://${formattedHost}:${port}${path}`;
}