mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
4259686d92
commit
4df1574339
15 changed files with 768 additions and 561 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
183
packages/ai/src/auth/oauth/callback-server.ts
Normal file
183
packages/ai/src/auth/oauth/callback-server.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
255
packages/ai/test/oauth-callback-server.test.ts
Normal file
255
packages/ai/test/oauth-callback-server.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue