chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
@@ -52,7 +52,7 @@ export function lazyOAuth(input: {
|
||||
name: input.name,
|
||||
isSubscription: input.isSubscription,
|
||||
loginLabel: input.loginLabel,
|
||||
login: async (interaction) => (await loaded()).login(interaction),
|
||||
login: async (interaction, options) => (await loaded()).login(interaction, options),
|
||||
refresh: async (credential, signal) => (await loaded()).refresh(credential, signal),
|
||||
toAuth: async (credential) => (await loaded()).toAuth(credential),
|
||||
};
|
||||
|
||||
@@ -5,49 +5,26 @@
|
||||
* 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";
|
||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
// Preferred so the port can be forwarded into containers or over SSH. Anthropic accepts any loopback port,
|
||||
// so login falls back to a free port when this one cannot be bound (#10571).
|
||||
const CALLBACK_PORT = 53692;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const COPY_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
|
||||
const ANTHROPIC_BROWSER_LOGIN_METHOD = "browser";
|
||||
const ANTHROPIC_COPY_CODE_LOGIN_METHOD = "copy_code";
|
||||
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 +73,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,22 +139,29 @@ 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 startCallbackServer = (port: number) =>
|
||||
startOAuthCallbackServer({
|
||||
providerName: "Anthropic",
|
||||
host: CALLBACK_HOST,
|
||||
port,
|
||||
path: CALLBACK_PATH,
|
||||
redirectHost: "localhost",
|
||||
state: verifier,
|
||||
complete: async (code) => code,
|
||||
signal: interaction.signal,
|
||||
});
|
||||
// Without a callback server, login continues with the pasted redirect URL.
|
||||
const callback = await startCallbackServer(CALLBACK_PORT)
|
||||
.catch(() => startCallbackServer(0))
|
||||
.catch(() => undefined);
|
||||
const redirectUri = callback?.redirectUri ?? REDIRECT_URI;
|
||||
|
||||
try {
|
||||
const authParams = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
redirect_uri: redirectUri,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
@@ -261,56 +174,66 @@ 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: redirectUri,
|
||||
});
|
||||
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, redirectUri, interaction.signal);
|
||||
} finally {
|
||||
interaction.signal.removeEventListener("abort", onAbort);
|
||||
manualAbort.abort();
|
||||
server.server.close();
|
||||
callback?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAnthropicCopyCode(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const authParams = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: COPY_CODE_REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
});
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
|
||||
instructions: "Complete login in your browser, then copy the code Anthropic shows and paste it here.",
|
||||
});
|
||||
|
||||
const input = await interaction.prompt({
|
||||
type: "manual_code",
|
||||
message: "Paste the code Anthropic shows after you sign in:",
|
||||
placeholder: "code#state",
|
||||
signal: interaction.signal,
|
||||
});
|
||||
const parsed = parseAuthorizationInput(input);
|
||||
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
|
||||
if (!parsed.code) throw new Error("Missing authorization code");
|
||||
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
|
||||
return await exchangeAuthorizationCode(
|
||||
parsed.code,
|
||||
parsed.state ?? verifier,
|
||||
verifier,
|
||||
COPY_CODE_REDIRECT_URI,
|
||||
interaction.signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Anthropic OAuth token
|
||||
*/
|
||||
@@ -355,7 +278,27 @@ async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal):
|
||||
export const anthropicOAuth: OAuthAuth = {
|
||||
name: "Anthropic (Claude Pro/Max)",
|
||||
isSubscription: true,
|
||||
login: loginAnthropic,
|
||||
|
||||
async login(interaction) {
|
||||
const method = await interaction.prompt({
|
||||
type: "select",
|
||||
message: "Select Anthropic login method:",
|
||||
options: [
|
||||
{ id: ANTHROPIC_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
|
||||
{ id: ANTHROPIC_COPY_CODE_LOGIN_METHOD, label: "Copy code login (headless)" },
|
||||
],
|
||||
});
|
||||
|
||||
if (method === ANTHROPIC_COPY_CODE_LOGIN_METHOD) {
|
||||
return loginAnthropicCopyCode(interaction);
|
||||
}
|
||||
if (method !== ANTHROPIC_BROWSER_LOGIN_METHOD) {
|
||||
throw new Error(`Unknown Anthropic login method: ${method}`);
|
||||
}
|
||||
|
||||
return loginAnthropic(interaction);
|
||||
},
|
||||
|
||||
refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal),
|
||||
|
||||
async toAuth(credential) {
|
||||
|
||||
183
third_party/pi/packages/ai/src/auth/oauth/callback-server.ts
vendored
Normal file
183
third_party/pi/packages/ai/src/auth/oauth/callback-server.ts
vendored
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();
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ const importOAuthModule = (specifier: string): Promise<unknown> => {
|
||||
type OAuthFlowLoaders = {
|
||||
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiChatGPT: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
meta: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
||||
};
|
||||
@@ -38,6 +40,11 @@ export const loadOpenAICodexOAuth = async (): Promise<OAuthAuth> => {
|
||||
return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth;
|
||||
};
|
||||
|
||||
export const loadOpenAIChatGPTOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.openaiChatGPT();
|
||||
return ((await importOAuthModule("./openai-chatgpt.ts")) as { openaiChatGPTOAuth: OAuthAuth }).openaiChatGPTOAuth;
|
||||
};
|
||||
|
||||
export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.githubCopilot();
|
||||
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
||||
@@ -53,6 +60,11 @@ export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
|
||||
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
|
||||
};
|
||||
|
||||
export const loadMetaOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.meta();
|
||||
return ((await importOAuthModule("./meta.ts")) as { metaOAuth: OAuthAuth }).metaOAuth;
|
||||
};
|
||||
|
||||
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.xai();
|
||||
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
|
||||
|
||||
208
third_party/pi/packages/ai/src/auth/oauth/meta.ts
vendored
Normal file
208
third_party/pi/packages/ai/src/auth/oauth/meta.ts
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Meta Model API OAuth flow
|
||||
*
|
||||
* RFC 8628 device authorization grant against https://auth.meta.com (JSON
|
||||
* responses). Meta splits identity from API access: the resulting identity
|
||||
* token is not accepted for inference, so it is exchanged for a Model API
|
||||
* key via the Muse Code key-mint endpoint (minted keys live about a day).
|
||||
* The identity token is stored as `refresh` and the minted key as `access`,
|
||||
* so the standard OAuth scheduler re-mints the key when it expires with no
|
||||
* bespoke renewal machinery. The identity token itself is not renewable
|
||||
* (auth.meta.com answers grant_type=refresh_token with 404 and issues no
|
||||
* refresh_token), so a 401/403 from mint means the session is dead and the
|
||||
* user must sign in again.
|
||||
*/
|
||||
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
|
||||
// Muse Code CLI client id.
|
||||
const CLIENT_ID = "1031625952748946";
|
||||
const AUTH_HOST = "https://auth.meta.com";
|
||||
const DEVICE_AUTHORIZATION_URL = `${AUTH_HOST}/oidc/device/authorization/`;
|
||||
const DEVICE_TOKEN_URL = `${AUTH_HOST}/oidc/device/token/`;
|
||||
const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key";
|
||||
const API_KEY_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
type DeviceAuthorization = {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
};
|
||||
|
||||
function requestSignal(signal: AbortSignal): AbortSignal {
|
||||
return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const json = await response.json();
|
||||
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorDetail(json: Record<string, unknown> | null): string {
|
||||
for (const key of ["error_description", "detail", "message", "error"]) {
|
||||
const value = json?.[key];
|
||||
if (typeof value === "string" && value.trim()) return `: ${value.trim()}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
|
||||
function trustedHttpUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
async function startDeviceAuthorization(signal: AbortSignal): Promise<DeviceAuthorization> {
|
||||
const response = await fetch(DEVICE_AUTHORIZATION_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
const json = await readJson(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Meta device authorization failed with status ${response.status}${errorDetail(json)}`);
|
||||
}
|
||||
const deviceCode = json?.device_code;
|
||||
const userCode = json?.user_code;
|
||||
const verificationUri = trustedHttpUrl(json?.verification_uri_complete) ?? trustedHttpUrl(json?.verification_uri);
|
||||
if (typeof deviceCode !== "string" || !deviceCode || typeof userCode !== "string" || !userCode || !verificationUri) {
|
||||
throw new Error(`Invalid Meta device authorization response: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return {
|
||||
deviceCode,
|
||||
userCode,
|
||||
verificationUri,
|
||||
intervalSeconds: positiveNumber(json?.interval),
|
||||
expiresInSeconds: positiveNumber(json?.expires_in),
|
||||
};
|
||||
}
|
||||
|
||||
async function pollForIdentityToken(device: DeviceAuthorization, signal: AbortSignal): Promise<string> {
|
||||
return pollOAuthDeviceCodeFlow<string>({
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
waitBeforeFirstPoll: true,
|
||||
signal,
|
||||
poll: async () => {
|
||||
const response = await fetch(DEVICE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: device.deviceCode,
|
||||
client_id: CLIENT_ID,
|
||||
}).toString(),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
const json = await readJson(response);
|
||||
if (response.ok && typeof json?.access_token === "string" && json.access_token) {
|
||||
return { status: "complete", value: json.access_token };
|
||||
}
|
||||
switch (json?.error) {
|
||||
case "authorization_pending":
|
||||
return { status: "pending" };
|
||||
case "slow_down":
|
||||
return { status: "slow_down", intervalSeconds: positiveNumber(json?.interval) };
|
||||
case "access_denied":
|
||||
return { status: "failed", message: "Meta login was denied." };
|
||||
case "expired_token":
|
||||
return { status: "failed", message: "Meta device authorization expired. Please restart login." };
|
||||
default:
|
||||
return {
|
||||
status: "failed",
|
||||
message: `Meta device token request failed with status ${response.status}${errorDetail(json)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Exchange an identity token for a Model API key. Keys are valid for about a day. */
|
||||
async function mintApiKey(identityToken: string, signal: AbortSignal): Promise<OAuthCredential> {
|
||||
const response = await fetch(API_KEY_MINT_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${identityToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-api-version": "1.0.0",
|
||||
},
|
||||
body: "{}",
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
const json = await readJson(response);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Identity token is not renewable (see file header); only a fresh device flow helps.
|
||||
throw new Error(
|
||||
`Meta session expired (status ${response.status}). Run \`/login meta\` to sign in again.${errorDetail(json)}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Meta API key mint failed with status ${response.status}${errorDetail(json)}`);
|
||||
}
|
||||
const apiKey = json?.api_key;
|
||||
if (typeof apiKey !== "string" || !apiKey) {
|
||||
const actionUrl = trustedHttpUrl(json?.action_url);
|
||||
throw new Error(`Meta did not issue an API key.${actionUrl ? ` Complete setup at ${actionUrl}` : ""}`);
|
||||
}
|
||||
return { type: "oauth", refresh: identityToken, access: apiKey, expires: Date.now() + API_KEY_LIFETIME_MS };
|
||||
}
|
||||
|
||||
async function loginMeta(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
try {
|
||||
const device = await startDeviceAuthorization(interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.userCode,
|
||||
verificationUri: device.verificationUri,
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
});
|
||||
const identityToken = await pollForIdentityToken(device, interaction.signal);
|
||||
interaction.notify({ type: "progress", message: "Enabling Meta Model API access..." });
|
||||
return await mintApiKey(identityToken, interaction.signal);
|
||||
} catch (e) {
|
||||
// An in-flight fetch rejects with a DOMException on abort; the login UI matches on this message.
|
||||
if (interaction.signal.aborted) throw new Error("Login cancelled");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export const metaOAuth: OAuthAuth = {
|
||||
name: "Meta (Muse subscription)",
|
||||
isSubscription: true,
|
||||
loginLabel: "Sign in with Meta",
|
||||
|
||||
login: loginMeta,
|
||||
|
||||
refresh: (credential, signal) => mintApiKey(credential.refresh, signal),
|
||||
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>`;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function renderPage(options: { title: string; heading: string; message: string; details?: string }): string {
|
||||
const title = escapeHtml(options.title);
|
||||
const heading = escapeHtml(options.heading);
|
||||
const message = escapeHtml(options.message);
|
||||
const details = options.details ? escapeHtml(options.details) : undefined;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
:root {
|
||||
--text: #fafafa;
|
||||
--text-dim: #a1a1aa;
|
||||
--page-bg: #09090b;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--page-bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
text-align: center;
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
}
|
||||
.details {
|
||||
margin-top: 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="logo">${LOGO_SVG}</div>
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
${details ? `<div class="details">${details}</div>` : ""}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function oauthSuccessHtml(message: string): string {
|
||||
return renderPage({
|
||||
title: "Authentication successful",
|
||||
heading: "Authentication successful",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function oauthErrorHtml(message: string, details?: string): string {
|
||||
return renderPage({
|
||||
title: "Authentication failed",
|
||||
heading: "Authentication failed",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
}
|
||||
309
third_party/pi/packages/ai/src/auth/oauth/openai-chatgpt.ts
vendored
Normal file
309
third_party/pi/packages/ai/src/auth/oauth/openai-chatgpt.ts
vendored
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* OpenAI Responses API token sharing through Sign in with ChatGPT.
|
||||
*
|
||||
* This public-client flow uses no client secret and sends the resulting user
|
||||
* access token directly to api.openai.com.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer, type Server, type ServerResponse } from "node:http";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts";
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
// every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
|
||||
const DYNAMIC_CLIENT_ID = "dynamic_agent_client";
|
||||
const AGENT_NAME_HINT = "Pi";
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize";
|
||||
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
|
||||
const RESOURCE = "https://api.openai.com/v1";
|
||||
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
const CALLBACK_PORT = 1455;
|
||||
const CALLBACK_PATH = "/auth/callback";
|
||||
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const DIRECT_TOKEN_SCOPE = "chatgpt.tokens.use.direct";
|
||||
const SCOPE = `openid profile email offline_access resource.invoke ${DIRECT_TOKEN_SCOPE}`;
|
||||
// Refresh this long before the real expiry so a request never starts with a token about to expire.
|
||||
const EXPIRY_MARGIN_MS = 3 * 60 * 1000;
|
||||
|
||||
type AuthorizationResult = {
|
||||
code: string;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
type CallbackServer = {
|
||||
server: Server;
|
||||
result: Promise<AuthorizationResult>;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
expires_in?: unknown;
|
||||
id_token?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
|
||||
function randomValue(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function authorizationResultFromCallback(url: URL, expectedState: string): AuthorizationResult {
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) throw new Error("Missing authorization code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!state) throw new Error("Missing OAuth state");
|
||||
if (state !== expectedState) throw new Error("OAuth state mismatch");
|
||||
const clientId = url.searchParams.get("client_id")?.trim();
|
||||
if (!clientId) throw new Error("OpenAI OAuth registration callback did not contain an issued client ID");
|
||||
return { code, clientId };
|
||||
}
|
||||
|
||||
function authorizationResultFromManualInput(input: string, expectedState: string): AuthorizationResult {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input.trim());
|
||||
} catch {
|
||||
throw new Error("Paste the full callback URL from the browser");
|
||||
}
|
||||
const expected = new URL(REDIRECT_URI);
|
||||
if (url.origin !== expected.origin || url.pathname !== expected.pathname) {
|
||||
throw new Error(`The pasted callback URL must start with ${REDIRECT_URI}`);
|
||||
}
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) throw new Error(`ChatGPT authorization failed: ${error}`);
|
||||
return authorizationResultFromCallback(url, expectedState);
|
||||
}
|
||||
|
||||
function sendHtml(response: ServerResponse, status: number, body: string): void {
|
||||
response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function startCallbackServer(expectedState: string): Promise<CallbackServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolveResult!: (result: AuthorizationResult) => void;
|
||||
let rejectResult!: (error: Error) => void;
|
||||
const result = new Promise<AuthorizationResult>((resolveAuthorization, rejectAuthorization) => {
|
||||
resolveResult = resolveAuthorization;
|
||||
rejectResult = rejectAuthorization;
|
||||
});
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url || "", REDIRECT_URI);
|
||||
if (url.pathname !== CALLBACK_PATH) {
|
||||
sendHtml(response, 404, oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) {
|
||||
sendHtml(response, 400, oauthErrorHtml("ChatGPT was not connected.", `Error: ${error}`));
|
||||
rejectResult(new Error(`ChatGPT authorization failed: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let authorizationResult: AuthorizationResult;
|
||||
try {
|
||||
authorizationResult = authorizationResultFromCallback(url, expectedState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid callback";
|
||||
sendHtml(response, 400, oauthErrorHtml(message));
|
||||
return;
|
||||
}
|
||||
|
||||
sendHtml(response, 200, oauthSuccessHtml("ChatGPT authentication completed. You can close this window."));
|
||||
resolveResult(authorizationResult);
|
||||
} catch {
|
||||
sendHtml(response, 500, oauthErrorHtml("Internal error while processing the callback."));
|
||||
}
|
||||
});
|
||||
|
||||
server.once("error", reject);
|
||||
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
|
||||
server.removeListener("error", reject);
|
||||
server.on("error", rejectResult);
|
||||
resolve({ server, result });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requestToken(body: URLSearchParams, signal: AbortSignal): Promise<TokenResponse> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.text().catch(() => "");
|
||||
throw new Error(`OpenAI OAuth token request failed (${response.status}): ${responseBody || response.statusText}`);
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new Error("OpenAI OAuth token response must be an object");
|
||||
}
|
||||
return data as TokenResponse;
|
||||
}
|
||||
|
||||
function requireTokenString(value: unknown, field: "access_token" | "refresh_token" | "scope"): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`OpenAI OAuth token response has invalid ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function credentialFromTokenResponse(token: TokenResponse, clientId: string): OAuthCredential {
|
||||
const access = requireTokenString(token.access_token, "access_token");
|
||||
const refresh = requireTokenString(token.refresh_token, "refresh_token");
|
||||
const scope = requireTokenString(token.scope, "scope");
|
||||
if (typeof token.expires_in !== "number" || !Number.isFinite(token.expires_in) || token.expires_in <= 0) {
|
||||
throw new Error("OpenAI OAuth token response has invalid expires_in");
|
||||
}
|
||||
const scopes = scope.trim().split(/\s+/).filter(Boolean);
|
||||
if (!scopes.includes(DIRECT_TOKEN_SCOPE)) {
|
||||
throw new Error(`OpenAI OAuth grant did not include ${DIRECT_TOKEN_SCOPE}`);
|
||||
}
|
||||
return {
|
||||
type: "oauth",
|
||||
access,
|
||||
refresh,
|
||||
expires: Date.now() + token.expires_in * 1000 - EXPIRY_MARGIN_MS,
|
||||
clientId,
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
clientId: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
const token = await requestToken(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
resource: RESOURCE,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
// Pi does not use the ID token to identify the user or read profile data.
|
||||
// Keep the presence check as part of the token-response contract.
|
||||
if (typeof token.id_token !== "string" || token.id_token.trim().length === 0) {
|
||||
throw new Error("OpenAI OAuth token response did not contain an ID token");
|
||||
}
|
||||
return credentialFromTokenResponse(token, clientId);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential> {
|
||||
const clientId = credential.clientId;
|
||||
if (typeof clientId !== "string" || clientId.trim().length === 0) {
|
||||
throw new Error("Stored OpenAI OAuth credential does not contain an issued client ID; reconnect ChatGPT");
|
||||
}
|
||||
const token = await requestToken(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: clientId,
|
||||
refresh_token: credential.refresh,
|
||||
resource: RESOURCE,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
return credentialFromTokenResponse(token, clientId);
|
||||
}
|
||||
|
||||
/** OpenAI identifies each installation ("agent host") by a stable URI such as `urn:uuid:<uuid>`. */
|
||||
function agentHostId(deviceId: string | undefined): string {
|
||||
if (!deviceId || !UUID_PATTERN.test(deviceId)) {
|
||||
throw new Error("Sign in with ChatGPT requires a device ID (UUID) for this installation");
|
||||
}
|
||||
return `urn:uuid:${deviceId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
async function loginOpenAIChatGPT(
|
||||
interaction: ProviderAuthInteraction,
|
||||
options?: LoginOptions,
|
||||
): Promise<OAuthCredential> {
|
||||
const hostId = agentHostId(options?.getDeviceId?.());
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const state = randomValue();
|
||||
const nonce = randomValue();
|
||||
// Without this server, the browser's callback would reach whatever else holds the port (another
|
||||
// pending login or the Codex CLI), which rejects it as a state mismatch. Fail with a clear error instead.
|
||||
const callback = await startCallbackServer(state).catch((error: unknown) => {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
|
||||
throw new Error(
|
||||
`Port ${CALLBACK_PORT} is in use, probably by an unfinished login in another pi session or by the Codex CLI. Cancel that login and try again.`,
|
||||
);
|
||||
});
|
||||
|
||||
const authorizationUrl = new URL(AUTHORIZE_URL);
|
||||
authorizationUrl.search = new URLSearchParams({
|
||||
client_id: DYNAMIC_CLIENT_ID,
|
||||
agent_name_hint: options?.agentName ?? AGENT_NAME_HINT,
|
||||
ext_agent_host_id: hostId,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
resource: RESOURCE,
|
||||
scope: SCOPE,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
nonce,
|
||||
}).toString();
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: authorizationUrl.toString(),
|
||||
instructions:
|
||||
"Complete sign-in in your browser. If the callback does not complete, paste the final redirect URL here.",
|
||||
});
|
||||
|
||||
const manualAbort = new AbortController();
|
||||
const manualCode = interaction
|
||||
.prompt({
|
||||
type: "manual_code",
|
||||
message: "Complete login in your browser, or paste the final redirect URL here:",
|
||||
placeholder: REDIRECT_URI,
|
||||
signal: AbortSignal.any([manualAbort.signal, interaction.signal]),
|
||||
})
|
||||
.then((input) => authorizationResultFromManualInput(input, state));
|
||||
|
||||
try {
|
||||
const result = await Promise.race([callback.result, manualCode]);
|
||||
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
|
||||
return await exchangeAuthorizationCode(result.code, verifier, result.clientId, interaction.signal);
|
||||
} catch (error) {
|
||||
if (interaction.signal.aborted) throw new Error("Login cancelled");
|
||||
throw error;
|
||||
} finally {
|
||||
manualAbort.abort();
|
||||
callback.server.close();
|
||||
// close() only stops accepting new connections. Browsers open spare connections ahead of
|
||||
// time, and one that has not sent a request yet stays open and attached to this server.
|
||||
// A later login in the same process starts a new server with a new state, but the browser
|
||||
// may send that login's callback over the spare connection. This server would then handle
|
||||
// it and reject it with "OAuth state mismatch", and the new login would never see it.
|
||||
callback.server.closeAllConnections();
|
||||
}
|
||||
}
|
||||
|
||||
export const openaiChatGPTOAuth: OAuthAuth = {
|
||||
name: "OpenAI (ChatGPT subscription)",
|
||||
isSubscription: true,
|
||||
loginLabel: "Sign in with ChatGPT",
|
||||
login: loginOpenAIChatGPT,
|
||||
refresh: refreshAccessToken,
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
@@ -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 type { LoginOptions, 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];
|
||||
@@ -442,16 +356,21 @@ 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;
|
||||
async function loginOpenAICodex(
|
||||
interaction: ProviderAuthInteraction,
|
||||
options?: LoginOptions,
|
||||
): Promise<OAuthCredential> {
|
||||
const { verifier, state, url } = await createAuthorizationFlow(options?.agentName);
|
||||
// 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 +379,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +410,7 @@ export const openaiCodexOAuth: OAuthAuth = {
|
||||
name: "OpenAI (ChatGPT Plus/Pro)",
|
||||
isSubscription: true,
|
||||
|
||||
async login(interaction) {
|
||||
async login(interaction, options) {
|
||||
const method = await interaction.prompt({
|
||||
type: "select",
|
||||
message: "Select OpenAI Codex login method:",
|
||||
@@ -533,7 +427,7 @@ export const openaiCodexOAuth: OAuthAuth = {
|
||||
throw new Error(`Unknown OpenAI Codex login method: ${method}`);
|
||||
}
|
||||
|
||||
return loginOpenAICodex(interaction);
|
||||
return loginOpenAICodex(interaction, options);
|
||||
},
|
||||
|
||||
refresh: (credential, signal) => refreshOpenAICodexToken(credential.refresh, signal),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
134
third_party/pi/packages/ai/src/auth/oauth/radius.ts
vendored
134
third_party/pi/packages/ai/src/auth/oauth/radius.ts
vendored
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
108
third_party/pi/packages/ai/src/auth/resolve.ts
vendored
108
third_party/pi/packages/ai/src/auth/resolve.ts
vendored
@@ -1,6 +1,9 @@
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts";
|
||||
import { formatThrownValue } from "../utils/diagnostics.ts";
|
||||
import { ModelsError } from "../utils/models-error.ts";
|
||||
|
||||
export { ModelsError, type ModelsErrorCode } from "../utils/models-error.ts";
|
||||
|
||||
import type {
|
||||
ApiKeyAuth,
|
||||
ApiKeyCredential,
|
||||
@@ -13,8 +16,6 @@ import type {
|
||||
ProviderAuth,
|
||||
} from "./types.ts";
|
||||
|
||||
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
|
||||
|
||||
export interface AuthResolutionOverrides {
|
||||
apiKey?: string;
|
||||
env?: ProviderEnv;
|
||||
@@ -23,26 +24,8 @@ export interface AuthResolutionOverrides {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class ModelsError extends Error {
|
||||
readonly code: ModelsErrorCode;
|
||||
|
||||
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
|
||||
super(withCauseDetail(message, options?.cause), options);
|
||||
this.name = "ModelsError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Callers surface `error.message` only, so keep the underlying reason in it. */
|
||||
function withCauseDetail(message: string, cause: unknown): string {
|
||||
if (cause === undefined || cause === null) return message;
|
||||
const detail = formatThrownValue(cause).trim();
|
||||
if (!detail || message.includes(detail)) return message;
|
||||
return `${message}: ${detail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth resolution shared by the `Models` and `ImagesModels` collections.
|
||||
* Auth resolution shared by all operations in a `Models` collection.
|
||||
* A stored credential owns the provider: ambient/env is consulted only when
|
||||
* nothing is stored. No silent env fallback after a failed refresh or for a
|
||||
* credential type without a matching handler.
|
||||
@@ -119,10 +102,61 @@ function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext
|
||||
const DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Refresh a stored OAuth credential under the credential-store lock and persist
|
||||
* the result before the lock is released. `needsRefresh` is re-checked under
|
||||
* the lock, so concurrent callers and processes refresh only once.
|
||||
*
|
||||
* `signal` cancels only the wait for the lock. Once a refresh starts, the
|
||||
* provider may already have rotated the refresh token, so the refresh and its
|
||||
* persistence ignore `signal` and are bounded only by a timeout. Otherwise a
|
||||
* cancelled caller could discard the only valid refresh token. Callers that
|
||||
* must return promptly on cancellation race this promise with their signal.
|
||||
*
|
||||
* Resolves with the stored OAuth credential after the operation, or undefined
|
||||
* when the provider no longer has an OAuth credential.
|
||||
*/
|
||||
export async function refreshStoredOAuthCredential(
|
||||
credentials: CredentialStore,
|
||||
providerId: string,
|
||||
oauth: OAuthAuth,
|
||||
needsRefresh: (credential: OAuthCredential) => boolean,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential | undefined> {
|
||||
let post: Credential | undefined;
|
||||
const lockWait = new AbortController();
|
||||
const cancelLockWait = () => lockWait.abort(signal.reason);
|
||||
signal.addEventListener("abort", cancelLockWait, { once: true });
|
||||
if (signal.aborted) cancelLockWait();
|
||||
try {
|
||||
post = await credentials.modify(
|
||||
providerId,
|
||||
async (current) => {
|
||||
signal.removeEventListener("abort", cancelLockWait);
|
||||
signal.throwIfAborted();
|
||||
if (current?.type !== "oauth") return undefined; // logged out meanwhile
|
||||
if (!needsRefresh(current)) return undefined; // another process/request refreshed
|
||||
try {
|
||||
return await oauth.refresh(current, AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS));
|
||||
} catch (error) {
|
||||
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
},
|
||||
{ signal: lockWait.signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelsError) throw error;
|
||||
signal.throwIfAborted();
|
||||
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
|
||||
} finally {
|
||||
signal.removeEventListener("abort", cancelLockWait);
|
||||
}
|
||||
return post?.type === "oauth" ? post : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth resolution with double-checked locking: tokens with less than five
|
||||
* minutes remaining lock, re-check expiry under the lock, refresh once
|
||||
* globally, and persist the rotated credential before release.
|
||||
* minutes remaining are refreshed through `refreshStoredOAuthCredential`.
|
||||
*/
|
||||
async function resolveStoredOAuth(
|
||||
credentials: CredentialStore,
|
||||
@@ -138,30 +172,8 @@ async function resolveStoredOAuth(
|
||||
|
||||
if (expiresSoon(credential)) {
|
||||
// Optimistic check said expired; the authoritative check runs under the lock.
|
||||
let post: Credential | undefined;
|
||||
try {
|
||||
post = await credentials.modify(
|
||||
providerId,
|
||||
async (current) => {
|
||||
if (current?.type !== "oauth") return undefined; // logged out meanwhile
|
||||
if (!expiresSoon(current)) return undefined; // another process/request refreshed
|
||||
try {
|
||||
const refreshSignal = AbortSignal.any([
|
||||
signal,
|
||||
AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS),
|
||||
]);
|
||||
return await oauth.refresh(current, refreshSignal);
|
||||
} catch (error) {
|
||||
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelsError) throw error;
|
||||
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
if (post?.type !== "oauth") return undefined; // logged out meanwhile
|
||||
const post = await refreshStoredOAuthCredential(credentials, providerId, oauth, expiresSoon, signal);
|
||||
if (!post) return undefined; // logged out meanwhile
|
||||
credential = post;
|
||||
// The normal five-minute window triggers a refresh but does not impose a
|
||||
// provider contract. Explicit callers (such as bearer-token export) do
|
||||
|
||||
17
third_party/pi/packages/ai/src/auth/types.ts
vendored
17
third_party/pi/packages/ai/src/auth/types.ts
vendored
@@ -198,6 +198,21 @@ export interface ApiKeyAuth {
|
||||
}): Promise<AuthResult | undefined>;
|
||||
}
|
||||
|
||||
/** App-supplied context for `Models.login`. */
|
||||
export interface LoginOptions {
|
||||
/**
|
||||
* Returns the stable ID of this app installation, e.g. sent to OpenAI as its
|
||||
* agent host ID. Called only by login flows that need it, so apps can create
|
||||
* the ID on first use and must return the same ID on every later call.
|
||||
*/
|
||||
getDeviceId?: () => string;
|
||||
/**
|
||||
* Name this app introduces itself with during login, e.g. OpenAI's agent name hint and
|
||||
* Codex originator. Defaults to pi's own name.
|
||||
*/
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
|
||||
* refresh pattern: `refresh` produces a credential, `toAuth` derives request
|
||||
@@ -213,7 +228,7 @@ export interface OAuthAuth {
|
||||
/** Selector label for the OAuth login option, e.g. "Sign in with SuperGrok or X Premium". */
|
||||
loginLabel?: string;
|
||||
|
||||
login(interaction: ProviderAuthInteraction): Promise<OAuthCredential>;
|
||||
login(interaction: ProviderAuthInteraction, options?: LoginOptions): Promise<OAuthCredential>;
|
||||
|
||||
/**
|
||||
* Exchange the refresh token. Network call; throws on failure
|
||||
|
||||
Reference in New Issue
Block a user