feat(ai,coding-agent): share OAuth callback server and sign-in page

Anthropic, OpenAI Codex, OpenRouter, and Radius now use one loopback callback server in pi-ai. MCP sign-in renders the same browser page through a new renderPage option on the pi-mcp callback server.
This commit is contained in:
Armin Ronacher 2026-09-29 12:10:29 +02:00
parent 4259686d92
commit 4df1574339
15 changed files with 768 additions and 561 deletions

View file

@ -21,6 +21,11 @@
- Added `onProviderStreamEvent` to observe parsed provider stream events before normalization, including provider-specific fields not retained in assistant messages ([#9784](https://github.com/earendil-works/pi/issues/9784)).
- Added Claude Sonnet 5.5 to the built-in Anthropic model catalog with adaptive thinking, mid-conversation effort, 1M context, and official pricing metadata.
### Changed
- Unified the Anthropic, OpenAI Codex, OpenRouter, and Radius browser sign-in callback servers into one shared implementation with the same browser pages. The OAuth page helpers are now available as `@earendil-works/pi-ai/utils/oauth-page`.
- Changed Radius browser sign-in to exchange the authorization code before showing the browser page, so token exchange failures are shown in the browser.
### Fixed
- Fixed 1-hour Anthropic cache writes reported by Vercel AI Gateway in streaming deltas being priced at the 5-minute rate ([#9210](https://github.com/earendil-works/pi/issues/9210)).
@ -30,6 +35,8 @@
- Fixed Mistral reasoning models ignoring the requested thinking level: GLM 5.3 now uses `reasoning_effort` instead of `prompt_mode`, GLM 5.2 accepts `max`, and Mistral models only offer the effort levels the API supports ([#9678](https://github.com/earendil-works/pi/issues/9678)).
- Fixed OpenCode Zen and OpenCode Go `qwen3.8-flash` thinking being replayed as plain text on later turns because the endpoint returns empty thinking signatures ([#10047](https://github.com/earendil-works/pi/issues/10047)).
- Fixed OpenAI Responses streams returning unfinished tool calls as runnable, which made servers that omit `output_index` (such as llama.cpp) run mixed-up commands; such streams now end with an error ([#9974](https://github.com/earendil-works/pi/issues/9974)).
- Fixed Anthropic and OpenAI Codex browser sign-in waiting indefinitely after the provider redirected with an authorization error; sign-in now fails with the provider's error description.
- Fixed Anthropic browser sign-in failing when its callback port is in use; it now falls back to pasting the redirect URL.
## [0.87.1] - 2026-09-22

View file

@ -5,26 +5,11 @@
* It is only intended for CLI use, not browser environments.
*/
import type { Server } from "node:http";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { generatePKCE } from "./pkce.ts";
type CallbackServerInfo = {
server: Server;
redirectUri: string;
cancelWait: () => void;
waitForCode: () => Promise<{ code: string; state: string } | null>;
};
type NodeApis = {
createServer: typeof import("node:http").createServer;
};
let nodeApis: NodeApis | null = null;
let nodeApisPromise: Promise<NodeApis> | null = null;
const decode = (s: string) => atob(s);
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
@ -35,19 +20,6 @@ const CALLBACK_PATH = "/callback";
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
const SCOPES =
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
async function getNodeApis(): Promise<NodeApis> {
if (nodeApis) return nodeApis;
if (!nodeApisPromise) {
if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) {
throw new Error("Anthropic OAuth is only available in Node.js environments");
}
nodeApisPromise = import("node:http").then((httpModule) => ({
createServer: httpModule.createServer,
}));
}
nodeApis = await nodeApisPromise;
return nodeApis;
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
@ -96,77 +68,6 @@ function formatErrorDetails(error: unknown): string {
return String(error);
}
async function startCallbackServer(expectedState: string): Promise<CallbackServerInfo> {
const { createServer } = await getNodeApis();
return new Promise((resolve, reject) => {
let settleWait: ((value: { code: string; state: string } | null) => void) | undefined;
const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => {
let settled = false;
settleWait = (value) => {
if (settled) return;
settled = true;
resolveWait(value);
};
});
const server = createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== CALLBACK_PATH) {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Callback route not found."));
return;
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`));
return;
}
if (!code || !state) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("Missing code or state parameter."));
return;
}
if (state !== expectedState) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthErrorHtml("State mismatch."));
return;
}
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window."));
settleWait?.({ code, state });
} catch {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Internal error");
}
});
server.on("error", (err) => {
reject(err);
});
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
server,
redirectUri: REDIRECT_URI,
cancelWait: () => {
settleWait?.(null);
},
waitForCode: () => waitForCodePromise,
});
});
});
}
async function postJson(url: string, body: Record<string, string | number>, signal: AbortSignal): Promise<string> {
const response = await fetch(url, {
method: "POST",
@ -233,15 +134,15 @@ async function exchangeAuthorizationCode(
async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const server = await startCallbackServer(verifier);
const manualAbort = new AbortController();
const onAbort = () => server.cancelWait();
interaction.signal.addEventListener("abort", onAbort, { once: true });
if (interaction.signal.aborted) onAbort();
let code: string | undefined;
let state: string | undefined;
let manualInput: string | undefined;
let manualError: Error | undefined;
const callback = await startOAuthCallbackServer({
providerName: "Anthropic",
host: CALLBACK_HOST,
port: CALLBACK_PORT,
path: CALLBACK_PATH,
state: verifier,
complete: async (code) => code,
signal: interaction.signal,
}).catch(() => undefined);
try {
const authParams = new URLSearchParams({
@ -261,53 +162,26 @@ async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAu
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
});
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
})
.then((input) => {
manualInput = input;
server.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
server.cancelWait();
});
const result = await server.waitForCode();
if (manualError) throw manualError;
if (result?.code) {
code = result.code;
state = result.state;
} else if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
const result = await waitForCallbackOrManualInput(interaction, callback, {
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
});
let code: string | undefined;
let state = verifier;
if (result.type === "callback") {
code = result.value;
} else {
const parsed = parseAuthorizationInput(result.input);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
code = parsed.code;
state = parsed.state ?? verifier;
}
if (!code) {
await manualPromise;
if (manualError) throw manualError;
if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
code = parsed.code;
state = parsed.state ?? verifier;
}
}
if (!code) throw new Error("Missing authorization code");
if (!state) throw new Error("Missing OAuth state");
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, interaction.signal);
return await exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, interaction.signal);
} finally {
interaction.signal.removeEventListener("abort", onAbort);
manualAbort.abort();
server.server.close();
callback?.close();
}
}

View file

@ -0,0 +1,183 @@
/**
* Loopback OAuth redirect handler shared by the browser sign-in flows.
*
* NOTE: This module uses node:http. It is only reachable through the lazily
* loaded OAuth flow modules, never from browser-facing entry points.
*/
import { createServer, type ServerResponse } from "node:http";
import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts";
import type { ProviderAuthInteraction } from "../types.ts";
export interface OAuthCallbackServerOptions<T> {
/** Provider name used on the browser page, for example `OpenAI`. */
providerName: string;
/** Address to listen on. */
host: string;
/** Port to listen on; `0` picks a free port. */
port: number;
path: string;
/** Host in `redirectUri` when it differs from `host`, for example `localhost`. */
redirectHost?: string;
/** Expected `state` parameter. Omit when the provider does not send one. */
state?: string;
/**
* Finishes the sign-in with the received code before the browser page is sent, so the page can
* show exchange failures. Pass `async (code) => code` to exchange the code later.
*/
complete: (code: string) => Promise<T>;
signal?: AbortSignal;
timeoutMs?: number;
}
export interface OAuthCallbackServer<T> {
redirectUri: string;
/**
* Resolves with the result of `complete`, or `undefined` after `cancel()`. Rejects when the provider
* redirects with an error, `complete` fails, the signal aborts, or the timeout elapses.
*/
wait(): Promise<T | undefined>;
/** Stop waiting for the browser unless a callback is already being completed. */
cancel(): void;
close(): void;
}
function sendPage(response: ServerResponse, status: number, html: string): void {
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
response.end(html);
}
export async function startOAuthCallbackServer<T>(
options: OAuthCallbackServerOptions<T>,
): Promise<OAuthCallbackServer<T>> {
const { providerName, signal } = options;
if (signal?.aborted) throw new Error("Login cancelled");
let resolveWait: (value: T | undefined) => void = () => {};
let rejectWait: (error: Error) => void = () => {};
const waitPromise = new Promise<T | undefined>((resolve, reject) => {
resolveWait = resolve;
rejectWait = reject;
});
// A cancelled or closed wait may never be observed.
waitPromise.catch(() => undefined);
let claimed = false;
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const onAbort = () => finish({ error: new Error("Login cancelled") });
const finish = (result: { value: T | undefined } | { error: Error }): void => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
if ("error" in result) rejectWait(result.error);
else resolveWait(result.value);
};
const server = createServer((request, response) => {
void (async () => {
const url = new URL(request.url ?? "/", "http://localhost");
if (request.method !== "GET" || url.pathname !== options.path) {
sendPage(response, 404, oauthErrorHtml("Callback route not found."));
return;
}
if (options.state !== undefined && url.searchParams.get("state") !== options.state) {
sendPage(response, 400, oauthErrorHtml("State mismatch."));
return;
}
if (claimed || settled) {
sendPage(response, 409, oauthErrorHtml("This sign-in has already been handled."));
return;
}
const error = url.searchParams.get("error");
if (error) {
const description = url.searchParams.get("error_description") ?? error;
sendPage(response, 400, oauthErrorHtml(`${providerName} authorization failed.`, description));
finish({ error: new Error(`${providerName} authorization failed: ${description}`) });
return;
}
const code = url.searchParams.get("code");
if (!code) {
sendPage(response, 400, oauthErrorHtml("Missing authorization code."));
return;
}
claimed = true;
try {
const value = await options.complete(code);
sendPage(response, 200, oauthSuccessHtml(`Signed in to ${providerName}. You may now close this page.`));
finish({ value });
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
sendPage(response, 502, oauthErrorHtml(`${providerName} sign-in failed.`, failure.message));
finish({ error: failure });
}
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(options.port, options.host, () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("OAuth callback server did not bind to TCP");
}
server.on("error", (error) => finish({ error }));
signal?.addEventListener("abort", onAbort, { once: true });
if (options.timeoutMs !== undefined) {
timer = setTimeout(() => finish({ error: new Error(`${providerName} sign-in timed out`) }), options.timeoutMs);
}
const redirectHost = options.redirectHost ?? options.host;
return {
redirectUri: `http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${options.path}`,
wait: () => waitPromise,
cancel: () => {
if (!claimed) finish({ value: undefined });
},
close: () => {
finish({ error: new Error("OAuth callback server closed") });
server.close();
},
};
}
/**
* Wait for the browser callback, or for the user to paste the code or redirect URL when the browser
* cannot reach the loopback server (for example over SSH). Without a callback server only the manual
* prompt is used.
*/
export async function waitForCallbackOrManualInput<T>(
interaction: ProviderAuthInteraction,
callback: OAuthCallbackServer<T> | undefined,
prompt: { message: string; placeholder: string },
): Promise<{ type: "callback"; value: T } | { type: "manual"; input: string }> {
const manualAbort = new AbortController();
let manualError: Error | undefined;
const manual = interaction
.prompt({ type: "manual_code", ...prompt, signal: manualAbort.signal })
.then((input) => {
callback?.cancel();
return input;
})
.catch((error: unknown) => {
manualError = error instanceof Error ? error : new Error(String(error));
callback?.cancel();
return undefined;
});
try {
const value = await callback?.wait();
if (manualError) throw manualError;
if (value !== undefined) return { type: "callback", value };
const input = await manual;
if (manualError) throw manualError;
return { type: "manual", input: input ?? "" };
} finally {
manualAbort.abort();
}
}

View file

@ -1,26 +1,22 @@
/**
* OpenAI Codex (ChatGPT OAuth) flow
*
* NOTE: This module uses Node.js crypto and http for the OAuth callback.
* NOTE: This module uses Node.js crypto and http (via callback-server.ts) for the OAuth callback.
* It is only intended for CLI use, not browser environments.
*/
// NEVER convert to top-level imports - breaks browser/Vite builds
let _randomBytes: typeof import("node:crypto").randomBytes | null = null;
let _http: typeof import("node:http") | null = null;
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
import("node:crypto").then((m) => {
_randomBytes = m.randomBytes;
});
import("node:http").then((m) => {
_http = m;
});
}
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@ -311,88 +307,6 @@ async function createAuthorizationFlow(
return { verifier, state, url: url.toString() };
}
type OAuthServerInfo = {
close: () => void;
cancelWait: () => void;
waitForCode: () => Promise<{ code: string } | null>;
};
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
if (!_http) {
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
}
let settleWait: ((value: { code: string } | null) => void) | undefined;
const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => {
let settled = false;
settleWait = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
});
const server = _http.createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== "/auth/callback") {
res.statusCode = 404;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Callback route not found."));
return;
}
if (url.searchParams.get("state") !== state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("State mismatch."));
return;
}
const code = url.searchParams.get("code");
if (!code) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Missing authorization code."));
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window."));
settleWait?.({ code });
} catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Internal error while processing OAuth callback."));
}
});
return new Promise((resolve) => {
server
.listen(1455, getCallbackHost(), () => {
resolve({
close: () => server.close(),
cancelWait: () => {
settleWait?.(null);
},
waitForCode: () => waitForCodePromise,
});
})
.on("error", (_err: NodeJS.ErrnoException) => {
settleWait?.(null);
resolve({
close: () => {
try {
server.close();
} catch {
// ignore
}
},
cancelWait: () => {},
waitForCode: async () => null,
});
});
});
}
function getAccountId(accessToken: string): string | null {
const payload = decodeJwt(accessToken);
const auth = payload?.[JWT_CLAIM_PATH];
@ -444,14 +358,16 @@ async function loginOpenAICodexDeviceCode(interaction: ProviderAuthInteraction):
async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, state, url } = await createAuthorizationFlow();
const server = await startLocalOAuthServer(state);
const manualAbort = new AbortController();
const onAbort = () => server.cancelWait();
interaction.signal.addEventListener("abort", onAbort, { once: true });
if (interaction.signal.aborted) onAbort();
let code: string | undefined;
let manualCode: string | undefined;
let manualError: Error | undefined;
// Port 1455 is shared with the Codex CLI; when it is taken, fall back to the pasted redirect URL.
const callback = await startOAuthCallbackServer({
providerName: "OpenAI",
host: getCallbackHost(),
port: 1455,
path: "/auth/callback",
state,
complete: async (code) => code,
signal: interaction.signal,
}).catch(() => undefined);
interaction.notify({
type: "auth_url",
@ -460,48 +376,23 @@ async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise<O
});
try {
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
})
.then((input) => {
manualCode = input;
server.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
server.cancelWait();
});
const result = await server.waitForCode();
if (manualError) throw manualError;
if (result?.code) {
code = result.code;
} else if (manualCode) {
const parsed = parseAuthorizationInput(manualCode);
const result = await waitForCallbackOrManualInput(interaction, callback, {
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
});
let code: string | undefined;
if (result.type === "callback") {
code = result.value;
} else {
const parsed = parseAuthorizationInput(result.input);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
if (!code) {
await manualPromise;
if (manualError) throw manualError;
if (manualCode) {
const parsed = parseAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
}
if (!code) throw new Error("Missing authorization code");
return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal);
return await exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal);
} finally {
interaction.signal.removeEventListener("abort", onAbort);
manualAbort.abort();
server.close();
callback?.close();
}
}

View file

@ -7,14 +7,13 @@
* manual prompt so remote/headless sessions can paste the redirect URL when
* the browser cannot reach the loopback server.
*
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
* NOTE: This module uses node:http (via callback-server.ts) for the OAuth callback server.
* It is only intended for CLI use, not browser environments.
*/
import { createServer, type Server, type ServerResponse } from "node:http";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { generatePKCE } from "./pkce.ts";
const AUTHORIZE_URL = "https://openrouter.ai/auth";
@ -28,27 +27,6 @@ function getCallbackHost(): string {
type JsonObject = Record<string, unknown>;
type OpenRouterCallbackServer = {
callbackUrl: string;
/** Stop listening and release timers without settling `waitForCredential`. */
close: () => void;
/** Hand the login over to manual code entry unless a callback already claimed the exchange. */
cancelWait: () => void;
/**
* Resolves with the credential once a browser callback completes the key
* exchange, or with null once `cancelWait` hands the login over to manual
* code entry. Rejects on timeout, cancellation, or a failed exchange.
*/
waitForCredential: () => Promise<OAuthCredential | null>;
};
function sendHtml(response: ServerResponse, status: number, html: string): void {
response.statusCode = status;
response.setHeader("content-type", "text/html; charset=utf-8");
response.setHeader("cache-control", "no-store");
response.end(html);
}
function parseAuthorizationInput(input: string): string | undefined {
const value = input.trim();
if (!value) return undefined;
@ -132,132 +110,30 @@ async function exchangeAuthorizationCode(
};
}
async function startCallbackServer(
callbackPath: string,
verifier: string,
signal: AbortSignal,
): Promise<OpenRouterCallbackServer> {
if (signal.aborted) throw new Error("Login cancelled");
const callbackHost = getCallbackHost();
let resolveCredential: (credential: OAuthCredential | null) => void = () => {};
let rejectCredential: (error: Error) => void = () => {};
const credential = new Promise<OAuthCredential | null>((resolve, reject) => {
resolveCredential = resolve;
rejectCredential = reject;
});
let server: Server;
let claimed = false;
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const close = (): void => {
if (timeout) clearTimeout(timeout);
if (onAbort) signal.removeEventListener("abort", onAbort);
server.close();
};
const finish = (result: { credential: OAuthCredential | null } | { error: Error }): void => {
if (settled) return;
settled = true;
close();
if ("credential" in result) resolveCredential(result.credential);
else rejectCredential(result.error);
};
server = createServer((request, response) => {
void (async () => {
const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`);
if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found."));
return;
}
if (claimed || settled) {
sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used."));
return;
}
const oauthError = requestUrl.searchParams.get("error");
if (oauthError) {
const description = requestUrl.searchParams.get("error_description") ?? oauthError;
sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description));
finish({ error: new Error(`OpenRouter authorization failed: ${description}`) });
return;
}
const code = requestUrl.searchParams.get("code");
if (!code) {
sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code."));
return;
}
claimed = true;
try {
const result = await exchangeAuthorizationCode(code, verifier, signal);
sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page."));
finish({ credential: result });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown token exchange error";
sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message));
finish({ error: error instanceof Error ? error : new Error(message) });
}
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, callbackHost, () => {
server.removeListener("error", reject);
resolve();
});
});
server.on("error", (error) => finish({ error }));
onAbort = () => finish({ error: new Error("Login cancelled") });
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
close();
throw new Error("Login cancelled");
}
timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS);
const address = server.address();
if (!address || typeof address === "string") {
close();
throw new Error("Could not determine the OpenRouter OAuth callback port");
}
return {
callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,
close,
// A claimed callback is already exchanging its code; let that exchange settle the login.
cancelWait: () => {
if (!claimed) finish({ credential: null });
},
waitForCredential: () => credential,
};
}
async function loginOpenRouter(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
const manualAbort = new AbortController();
let manualInput: string | undefined;
let manualError: Error | undefined;
// OpenRouter sends no `state`; the random path keeps stray requests from completing the sign-in.
const callback = await startOAuthCallbackServer({
providerName: "OpenRouter",
host: getCallbackHost(),
port: 0,
path: `/oauth/callback/${crypto.randomUUID()}`,
complete: (code) => exchangeAuthorizationCode(code, verifier, interaction.signal),
signal: interaction.signal,
timeoutMs: LOGIN_TIMEOUT_MS,
});
try {
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.search = new URLSearchParams({
callback_url: callback.callbackUrl,
callback_url: callback.redirectUri,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
interaction.notify({
type: "progress",
message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
message: `Listening for OpenRouter OAuth callback on ${callback.redirectUri}`,
});
interaction.notify({
type: "auth_url",
@ -266,34 +142,16 @@ async function loginOpenRouter(interaction: ProviderAuthInteraction): Promise<OA
"Complete sign-in in your browser. If the browser is on another machine, paste the final redirect URL here.",
});
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
placeholder: callback.callbackUrl,
signal: manualAbort.signal,
})
.then((input) => {
manualInput = input;
callback.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
callback.cancelWait();
});
const credential = await callback.waitForCredential();
if (manualError) throw manualError;
if (credential) return credential;
await manualPromise;
if (manualError) throw manualError;
const code = manualInput ? parseAuthorizationInput(manualInput) : undefined;
const result = await waitForCallbackOrManualInput(interaction, callback, {
message: "Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
placeholder: callback.redirectUri,
});
if (result.type === "callback") return result.value;
const code = parseAuthorizationInput(result.input);
if (!code) throw new Error("Missing authorization code");
interaction.notify({ type: "progress", message: "Exchanging authorization code for an API key..." });
return await exchangeAuthorizationCode(code, verifier, interaction.signal);
} finally {
manualAbort.abort();
callback.close();
}
}

View file

@ -5,22 +5,14 @@
* gateway; only the interactive browser authorization endpoint is discovered.
* Model catalog loading is owned by the Radius provider.
*
* NOTE: This module uses node:http for the OAuth callback server.
* NOTE: This module uses node:http (via callback-server.ts) for the OAuth callback server.
* It is only intended for CLI use, not browser environments.
*/
// NEVER convert to top-level imports - breaks browser/Vite builds
let _http: typeof import("node:http") | null = null;
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
import("node:http").then((m) => {
_http = m;
});
}
import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const CALLBACK_HOST = "127.0.0.1";
@ -139,84 +131,6 @@ async function requestOAuthToken(
};
}
type OAuthCallbackServer = {
waitForCode(): Promise<string | null>;
close(): void;
};
function startOAuthCallbackServer(expectedState: string, signal: AbortSignal): Promise<OAuthCallbackServer> {
if (!_http) {
throw new Error("Radius OAuth is only available in Node.js environments");
}
let settle: (code: string | null) => void = () => {};
let settled = false;
const wait = new Promise<string | null>((resolve) => {
settle = resolve;
});
const finish = (code: string | null) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
settle(code);
};
const onAbort = () => finish(null);
signal.addEventListener("abort", onAbort, { once: true });
const sendPage = (response: import("node:http").ServerResponse, status: number, html: string) => {
response.statusCode = status;
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(html);
};
const server = _http.createServer((request, response) => {
const url = new URL(request.url ?? "/", REDIRECT_URI);
if (url.pathname !== CALLBACK_PATH) {
sendPage(response, 404, oauthErrorHtml("Callback route not found."));
return;
}
if (url.searchParams.get("state") !== expectedState) {
sendPage(response, 400, oauthErrorHtml("OAuth state mismatch."));
return;
}
const error = url.searchParams.get("error");
if (error) {
sendPage(response, 400, oauthErrorHtml(url.searchParams.get("error_description") ?? error));
finish(null);
return;
}
const code = url.searchParams.get("code");
if (!code) {
sendPage(response, 400, oauthErrorHtml("Missing authorization code."));
return;
}
sendPage(response, 200, oauthSuccessHtml("Signed in to Radius. You may now close this page."));
finish(code);
});
return new Promise((resolve) => {
server
.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
waitForCode: () => wait,
close: () => {
finish(null);
server.close();
},
});
})
.once("error", () => {
finish(null);
resolve({ waitForCode: async () => null, close: () => {} });
});
});
}
async function loginWithBrowser(
gateway: string,
authorizationEndpoint: string,
@ -236,7 +150,26 @@ async function loginWithBrowser(
state,
}).toString();
const callbackServer = await startOAuthCallbackServer(state, interaction.signal);
const callback = await startOAuthCallbackServer({
providerName: "Radius",
host: CALLBACK_HOST,
port: CALLBACK_PORT,
path: CALLBACK_PATH,
state,
complete: (code) =>
requestOAuthToken(
gateway,
new URLSearchParams({
grant_type: "authorization_code",
client_id: OAUTH_CLIENT_ID,
redirect_uri: REDIRECT_URI,
code,
code_verifier: verifier,
}),
interaction.signal,
),
signal: interaction.signal,
});
interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` });
interaction.notify({
type: "auth_url",
@ -245,26 +178,11 @@ async function loginWithBrowser(
});
try {
const code = await callbackServer.waitForCode();
if (!code) {
if (interaction.signal.aborted) {
throw new Error("Login cancelled");
}
throw new Error("OAuth callback did not complete.");
}
return await requestOAuthToken(
gateway,
new URLSearchParams({
grant_type: "authorization_code",
client_id: OAUTH_CLIENT_ID,
redirect_uri: REDIRECT_URI,
code,
code_verifier: verifier,
}),
interaction.signal,
);
const credential = await callback.wait();
if (!credential) throw new Error("OAuth callback did not complete.");
return credential;
} finally {
callbackServer.close();
callback.close();
}
}

View file

@ -3,6 +3,7 @@ import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
const neverAbortedSignal = new AbortController().signal;
const nativeFetch = globalThis.fetch;
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
@ -141,4 +142,37 @@ describe.sequential("Anthropic OAuth", () => {
// the prompt's signal is aborted once login settles, so UIs can dismiss it
expect(manualSignal?.aborted).toBe(true);
});
it("completes login through the browser callback and shows the sign-in page", async () => {
let exchangedCode: string | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token")
return nativeFetch(input as string, init);
exchangedCode = getJsonBody(init).code;
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
}),
);
let callbackPage: Promise<Response> | undefined;
const credential = await anthropicOAuth.login({
signal: neverAbortedSignal,
notify: (event) => {
if (event.type !== "auth_url") return;
const state = new URL(event.url).searchParams.get("state") ?? "";
callbackPage = nativeFetch(`http://127.0.0.1:53692/callback?code=browser-code&state=${state}`);
},
prompt: (prompt) =>
new Promise((_, reject) => {
prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
}),
});
expect(credential.access).toBe("access");
expect(exchangedCode).toBe("browser-code");
const response = await callbackPage;
expect(response?.status).toBe(200);
expect(await response?.text()).toContain("Signed in to Anthropic.");
});
});

View file

@ -0,0 +1,255 @@
import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "../src/auth/oauth/callback-server.ts";
import type { AuthPrompt, ProviderAuthInteraction } from "../src/auth/types.ts";
const nativeFetch = globalThis.fetch;
function callbackUrl(redirectUri: string, params: Record<string, string>): string {
const url = new URL(redirectUri);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
return url.toString();
}
async function page(response: Response): Promise<{ status: number; contentType: string | null; body: string }> {
return { status: response.status, contentType: response.headers.get("content-type"), body: await response.text() };
}
function interaction(prompt: (prompt: AuthPrompt) => Promise<string>, signal?: AbortSignal): ProviderAuthInteraction {
return { signal: signal ?? new AbortController().signal, notify: () => {}, prompt };
}
/** A manual prompt that stays open until its signal aborts. */
function pendingPrompt(onPrompt?: (prompt: AuthPrompt) => void): (prompt: AuthPrompt) => Promise<string> {
return (prompt) => {
onPrompt?.(prompt);
return new Promise((_, reject) => {
prompt.signal?.addEventListener("abort", () => reject(new Error("prompt aborted")), { once: true });
});
};
}
describe.sequential("OAuth callback server", () => {
const servers: { close(): void }[] = [];
const start = async <T>(options: Partial<Parameters<typeof startOAuthCallbackServer<T>>[0]> = {}) => {
const server = await startOAuthCallbackServer<T>({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
state: "expected-state",
complete: async (code) => `completed:${code}` as T,
...options,
});
servers.push(server);
return server;
};
afterEach(() => {
for (const server of servers.splice(0)) server.close();
});
it("ignores stray requests and resolves with the completed code", async () => {
const server = await start<string>();
expect(server.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/);
const wrongPath = await page(await nativeFetch(new URL("/other", server.redirectUri)));
expect(wrongPath.status).toBe(404);
const wrongState = await page(await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "other" })));
expect(wrongState).toMatchObject({ status: 400, contentType: "text/html; charset=utf-8" });
expect(wrongState.body).toContain("State mismatch.");
const post = await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" }), {
method: "POST",
});
expect(post.status).toBe(404);
const missingCode = await page(await nativeFetch(callbackUrl(server.redirectUri, { state: "expected-state" })));
expect(missingCode.status).toBe(400);
const success = await page(
await nativeFetch(callbackUrl(server.redirectUri, { code: "the-code", state: "expected-state" })),
);
expect(success).toMatchObject({ status: 200, contentType: "text/html; charset=utf-8" });
expect(success.body).toContain("Authentication successful");
expect(success.body).toContain("Signed in to Example.");
await expect(server.wait()).resolves.toBe("completed:the-code");
});
it("uses the redirect host and skips the state check when none is expected", async () => {
const server = await start<string>({ redirectHost: "localhost", state: undefined });
expect(server.redirectUri).toMatch(/^http:\/\/localhost:\d+\/callback$/);
const response = await nativeFetch(callbackUrl(server.redirectUri, { code: "no-state" }));
expect(response.status).toBe(200);
await expect(server.wait()).resolves.toBe("completed:no-state");
});
it("shows completion failures on the page and rejects the wait", async () => {
const server = await start<string>({
complete: async () => {
throw new Error("token exchange failed");
},
});
const failure = await page(
await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" })),
);
expect(failure.status).toBe(502);
expect(failure.body).toContain("Example sign-in failed.");
expect(failure.body).toContain("token exchange failed");
await expect(server.wait()).rejects.toThrow("token exchange failed");
});
it("rejects the wait when the provider redirects with an error", async () => {
const server = await start<string>();
const failure = await page(
await nativeFetch(
callbackUrl(server.redirectUri, {
error: "access_denied",
error_description: "User denied access",
state: "expected-state",
}),
),
);
expect(failure.status).toBe(400);
expect(failure.body).toContain("User denied access");
await expect(server.wait()).rejects.toThrow("Example authorization failed: User denied access");
});
it("completes only the first callback", async () => {
let finishExchange: ((value: string) => void) | undefined;
const server = await start<string>({
complete: () =>
new Promise<string>((resolve) => {
finishExchange = resolve;
}),
});
const url = callbackUrl(server.redirectUri, { code: "c", state: "expected-state" });
const first = nativeFetch(url);
await vi.waitFor(() => expect(finishExchange).toBeDefined());
const second = await nativeFetch(url);
expect(second.status).toBe(409);
// A claimed callback keeps completing even when the caller switches to manual input.
server.cancel();
finishExchange?.("done");
expect((await first).status).toBe(200);
await expect(server.wait()).resolves.toBe("done");
});
it("resolves with undefined after cancel", async () => {
const server = await start<string>();
server.cancel();
await expect(server.wait()).resolves.toBeUndefined();
const late = await nativeFetch(callbackUrl(server.redirectUri, { code: "c", state: "expected-state" }));
expect(late.status).toBe(409);
});
it("rejects the wait on abort and on timeout", async () => {
const controller = new AbortController();
const aborted = await start<string>({ signal: controller.signal });
controller.abort();
await expect(aborted.wait()).rejects.toThrow("Login cancelled");
const timedOut = await start<string>({ timeoutMs: 10 });
await expect(timedOut.wait()).rejects.toThrow("Example sign-in timed out");
const alreadyAborted = new AbortController();
alreadyAborted.abort();
await expect(start<string>({ signal: alreadyAborted.signal })).rejects.toThrow("Login cancelled");
});
it("fails instead of picking another port when the requested port is taken", async () => {
const blocker: Server = createServer();
await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", resolve));
try {
const address = blocker.address();
if (!address || typeof address === "string") throw new Error("blocker did not bind to TCP");
await expect(start<string>({ port: address.port })).rejects.toMatchObject({ code: "EADDRINUSE" });
} finally {
blocker.close();
}
});
});
describe.sequential("waitForCallbackOrManualInput", () => {
it("returns the browser callback and aborts the manual prompt", async () => {
let manualSignal: AbortSignal | undefined;
const server = await startOAuthCallbackServer({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
complete: async (code) => code,
});
try {
const result = waitForCallbackOrManualInput(
interaction(
pendingPrompt((prompt) => {
manualSignal = prompt.signal;
}),
),
server,
{ message: "paste", placeholder: server.redirectUri },
);
await nativeFetch(callbackUrl(server.redirectUri, { code: "from-browser" }));
await expect(result).resolves.toEqual({ type: "callback", value: "from-browser" });
expect(manualSignal?.aborted).toBe(true);
} finally {
server.close();
}
});
it("returns pasted input and stops waiting for the browser", async () => {
const server = await startOAuthCallbackServer({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
complete: async (code) => code,
});
try {
const result = await waitForCallbackOrManualInput(
interaction(async () => "pasted"),
server,
{
message: "paste",
placeholder: server.redirectUri,
},
);
expect(result).toEqual({ type: "manual", input: "pasted" });
} finally {
server.close();
}
});
it("uses only the manual prompt without a callback server", async () => {
const result = await waitForCallbackOrManualInput(
interaction(async () => "pasted"),
undefined,
{
message: "paste",
placeholder: "http://localhost/callback",
},
);
expect(result).toEqual({ type: "manual", input: "pasted" });
});
it("propagates manual prompt failures", async () => {
const server = await startOAuthCallbackServer({
providerName: "Example",
host: "127.0.0.1",
port: 0,
path: "/callback",
complete: async (code) => code,
});
try {
const result = waitForCallbackOrManualInput(
interaction(async () => {
throw new Error("prompt cancelled");
}),
server,
{ message: "paste", placeholder: server.redirectUri },
);
await expect(result).rejects.toThrow("prompt cancelled");
} finally {
server.close();
}
});
});

View file

@ -1,3 +1,4 @@
import { createServer } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
@ -483,4 +484,48 @@ describe("OpenAI Codex OAuth", () => {
).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/);
expect(consoleError).not.toHaveBeenCalled();
});
it("falls back to the pasted redirect URL when the fixed callback port is taken", async () => {
// Port 1455 is registered with OpenAI; the Codex CLI may hold it. Occupy it unless it already is.
const blocker = createServer();
await new Promise<void>((resolve) => {
blocker.once("error", () => resolve());
blocker.listen(1455, "127.0.0.1", () => resolve());
});
try {
let exchangeBody: URLSearchParams | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit) => {
expect(getUrl(input)).toBe("https://auth.openai.com/oauth/token");
exchangeBody = new URLSearchParams(String(init?.body));
return jsonResponse({
access_token: createAccessToken("acct"),
refresh_token: "refresh",
expires_in: 3600,
});
}),
);
let authUrl = "";
const credential = await openaiCodexOAuth.login({
signal: neverAbortedSignal,
notify: (event) => {
if (event.type === "auth_url") authUrl = event.url;
},
prompt: async (prompt) => {
if (prompt.type === "select") return "browser";
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const state = new URL(authUrl).searchParams.get("state");
return `http://localhost:1455/auth/callback?code=pasted-code&state=${state}`;
},
});
expect(credential.accountId).toBe("acct");
expect(exchangeBody?.get("code")).toBe("pasted-code");
expect(exchangeBody?.get("redirect_uri")).toBe("http://localhost:1455/auth/callback");
} finally {
blocker.close();
}
});
});

View file

@ -3,6 +3,7 @@ import { createRadiusOAuth } from "../src/auth/oauth/radius.ts";
import type { AuthEvent, ProviderAuthInteraction } from "../src/auth/types.ts";
const GATEWAY = "https://radius.example";
const nativeFetch = globalThis.fetch;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@ -126,4 +127,56 @@ describe("Radius OAuth", () => {
await expect(oauth.login(interaction("browser"))).rejects.toThrow(`Invalid Radius OAuth config from ${GATEWAY}`);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("exchanges the browser callback code before showing the sign-in page", async () => {
let tokenStatus = 400;
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit) => {
const url = requestUrl(input);
if (url === `${GATEWAY}/v1/oauth`) return jsonResponse({ authorizationEndpoint: `${GATEWAY}/authorize` });
if (url === `${GATEWAY}/v1/oauth/token`) {
const body = new URLSearchParams(String(init?.body));
expect(body.get("code")).toBe("browser-code");
return tokenStatus === 200
? jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 })
: jsonResponse({ error: "invalid_grant", error_description: "code expired" }, tokenStatus);
}
return nativeFetch(url, init);
}),
);
const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY });
const login = async () => {
let callbackPage: Promise<Response> | undefined;
const result = oauth.login({
...interaction("browser"),
notify: (event) => {
if (event.type !== "auth_url") return;
const authorize = new URL(event.url);
const callback = new URL(authorize.searchParams.get("redirect_uri") ?? "");
callback.searchParams.set("code", "browser-code");
callback.searchParams.set("state", authorize.searchParams.get("state") ?? "");
callbackPage = nativeFetch(callback);
},
});
const outcome = await result.then(
(credential) => ({ credential }),
(error: Error) => ({ error }),
);
const response = await callbackPage;
return { ...outcome, status: response?.status, page: await response?.text() };
};
const failed = await login();
expect("error" in failed && failed.error.message).toContain("invalid_grant: code expired");
expect(failed.status).toBe(502);
expect(failed.page).toContain("code expired");
tokenStatus = 200;
const succeeded = await login();
expect("credential" in succeeded && succeeded.credential.access).toBe("access");
expect(succeeded.status).toBe(200);
expect(succeeded.page).toContain("Signed in to Radius.");
});
});

View file

@ -12,6 +12,7 @@
import { createHash } from "node:crypto";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { oauthErrorHtml, oauthSuccessHtml } from "@earendil-works/pi-ai/utils/oauth-page";
import type { AuthProvider, McpFetch } from "@earendil-works/pi-mcp";
import {
authorizeMcp,
@ -19,6 +20,7 @@ import {
McpOAuthProvider,
type McpOAuthState,
type McpOAuthStateStore,
type OAuthCallbackPage,
OAuthCallbackServer,
type OAuthChallenge,
type OAuthClientInformationMixed,
@ -346,7 +348,15 @@ async function listenForCallback(
port: number | undefined,
required: boolean,
): Promise<OAuthCallbackServer> {
const options = { host: settings.host, redirectHost: settings.redirectHost, path: settings.path };
const options = {
host: settings.host,
redirectHost: settings.redirectHost,
path: settings.path,
renderPage: (page: OAuthCallbackPage) =>
page.ok
? oauthSuccessHtml("Signed in to the MCP server. You may now close this page.")
: oauthErrorHtml(page.message, page.details),
};
try {
return await OAuthCallbackServer.listen({ ...options, port: port ?? 0 });
} catch (error) {

View file

@ -6,6 +6,9 @@ export interface OAuthCallback {
iss?: string;
}
/** Outcome shown on the browser page after the redirect. */
export type OAuthCallbackPage = { ok: true } | { ok: false; message: string; details?: string };
export interface OAuthCallbackServerOptions {
/** Address to listen on. Default: `127.0.0.1`. */
host?: string;
@ -17,10 +20,13 @@ export interface OAuthCallbackServerOptions {
port?: number;
path?: string;
timeoutMs?: number;
/** Render the browser page as HTML. Default: a plain-text message. */
renderPage?: (page: OAuthCallbackPage) => string;
}
function reply(response: ServerResponse, status: number, text: string): void {
response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(text);
function plainText(page: OAuthCallbackPage): string {
if (page.ok) return "Authorization complete. You may close this window.";
return page.details ? `${page.message}\n\n${page.details}` : page.message;
}
export class OAuthCallbackServer {
@ -28,6 +34,7 @@ export class OAuthCallbackServer {
private server: Server;
private path: string;
private timeoutMs: number;
private renderPage: ((page: OAuthCallbackPage) => string) | undefined;
private pending = new Map<
string,
{
@ -37,11 +44,18 @@ export class OAuthCallbackServer {
}
>();
private constructor(server: Server, redirectUrl: string, path: string, timeoutMs: number) {
private constructor(
server: Server,
redirectUrl: string,
path: string,
timeoutMs: number,
renderPage: ((page: OAuthCallbackPage) => string) | undefined,
) {
this.server = server;
this.redirectUrl = redirectUrl;
this.path = path;
this.timeoutMs = timeoutMs;
this.renderPage = renderPage;
}
static async listen(options: OAuthCallbackServerOptions = {}): Promise<OAuthCallbackServer> {
@ -64,6 +78,7 @@ export class OAuthCallbackServer {
`http://${redirectHost.includes(":") ? `[${redirectHost}]` : redirectHost}:${address.port}${path}`,
path,
options.timeoutMs ?? 5 * 60_000,
options.renderPage,
);
return instance;
}
@ -90,34 +105,48 @@ export class OAuthCallbackServer {
});
}
private reply(response: ServerResponse, status: number, page: OAuthCallbackPage): void {
if (this.renderPage) {
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
response.end(this.renderPage(page));
} else {
response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(plainText(page));
}
}
private handle(rawUrl: string, response: ServerResponse): void {
const url = new URL(rawUrl, this.redirectUrl);
if (url.pathname !== this.path) {
reply(response, 404, "Not found");
this.reply(response, 404, { ok: false, message: "Not found" });
return;
}
const state = url.searchParams.get("state");
const pending = state ? this.pending.get(state) : undefined;
if (!state || !pending) {
reply(response, 400, "Invalid or expired OAuth state");
this.reply(response, 400, { ok: false, message: "Invalid or expired OAuth state" });
return;
}
clearTimeout(pending.timer);
this.pending.delete(state);
const error = url.searchParams.get("error");
if (error) {
pending.reject(new Error(url.searchParams.get("error_description") ?? error));
reply(response, 200, "Authorization failed. You may close this window.");
const description = url.searchParams.get("error_description") ?? error;
pending.reject(new Error(description));
this.reply(response, 200, {
ok: false,
message: "Authorization failed. You may close this window.",
details: description,
});
return;
}
const code = url.searchParams.get("code");
if (!code) {
pending.reject(new Error("OAuth callback did not include an authorization code"));
reply(response, 400, "Missing authorization code");
this.reply(response, 400, { ok: false, message: "Missing authorization code" });
return;
}
const iss = url.searchParams.get("iss");
pending.resolve({ code, state, ...(iss ? { iss } : {}) });
reply(response, 200, "Authorization complete. You may close this window.");
this.reply(response, 200, { ok: true });
}
}

View file

@ -1,4 +1,9 @@
export { type OAuthCallback, OAuthCallbackServer, type OAuthCallbackServerOptions } from "./callback.ts";
export {
type OAuthCallback,
type OAuthCallbackPage,
OAuthCallbackServer,
type OAuthCallbackServerOptions,
} from "./callback.ts";
export {
buildAuthorizationServerDiscoveryUrls,
discoverAuthorizationServerMetadata,

View file

@ -8,6 +8,7 @@ import {
McpOAuthAuthorizationRequiredError,
McpOAuthProvider,
MemoryOAuthStateStore,
type OAuthCallbackPage,
OAuthCallbackServer,
type OAuthClientInformationMixed,
type OAuthClientProvider,
@ -399,3 +400,47 @@ describe("MCP OAuth", () => {
await expect(discoverAuthorizationServerMetadata(origin)).rejects.toBeInstanceOf(OAuthIssuerMismatchError);
});
});
describe("OAuthCallbackServer pages", () => {
it("renders plain text by default", async () => {
const callback = await OAuthCallbackServer.listen();
try {
const pending = callback.waitForCallback("s1");
const response = await fetch(`${callback.redirectUrl}?code=abc&state=s1`);
expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8");
expect(await response.text()).toBe("Authorization complete. You may close this window.");
expect((await pending).code).toBe("abc");
} finally {
await callback.close();
}
});
it("renders pages through renderPage", async () => {
const pages: OAuthCallbackPage[] = [];
const callback = await OAuthCallbackServer.listen({
renderPage: (page) => {
pages.push(page);
return page.ok ? "<p>ok</p>" : `<p>${page.message}</p>`;
},
});
try {
const denied = callback.waitForCallback("s1");
denied.catch(() => undefined);
const failure = await fetch(`${callback.redirectUrl}?error=access_denied&error_description=Denied&state=s1`);
expect(failure.headers.get("content-type")).toBe("text/html; charset=utf-8");
await expect(denied).rejects.toThrow("Denied");
expect(pages.at(-1)).toEqual({
ok: false,
message: "Authorization failed. You may close this window.",
details: "Denied",
});
const pending = callback.waitForCallback("s2");
const success = await fetch(`${callback.redirectUrl}?code=abc&state=s2`);
expect(await success.text()).toBe("<p>ok</p>");
expect((await pending).code).toBe("abc");
} finally {
await callback.close();
}
});
});