feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { AuthContext } from "./types.ts";
interface NodeFsModule {
access(path: string): Promise<void>;
}
interface NodeOsModule {
homedir(): string;
}
// Variable specifier so browser bundlers do not try to resolve node builtins.
const importNodeModule = (specifier: string): Promise<unknown> => import(specifier);
function getProcessEnv(): Record<string, string | undefined> | undefined {
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
return proc?.env;
}
/**
* Default auth context: env vars from `process.env` (undefined in browsers),
* file existence via node:fs (always false in browsers).
*/
export function defaultProviderAuthContext(): AuthContext {
return {
async env(name: string): Promise<string | undefined> {
const value = getProcessEnv()?.[name];
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
},
async fileExists(path: string): Promise<boolean> {
try {
const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule;
let resolved = path;
if (resolved.startsWith("~")) {
const os = (await importNodeModule("node:os")) as NodeOsModule;
resolved = os.homedir() + resolved.slice(1);
}
await fs.access(resolved);
return true;
} catch {
return false;
}
},
};
}
@@ -0,0 +1,51 @@
import type { Credential, CredentialInfo, CredentialStore } from "./types.ts";
/**
* Default in-memory credential store. Apps inject persistent stores.
* Keyed by `Provider.id`, one credential per provider; see `CredentialStore`.
* Writes are serialized per provider through a promise chain.
*/
export class InMemoryCredentialStore implements CredentialStore {
private credentials = new Map<string, Credential>();
private chains = new Map<string, Promise<unknown>>();
/** Serialize tasks per provider id. */
private enqueue<T>(providerId: string, task: () => Promise<T>): Promise<T> {
const previous = this.chains.get(providerId) ?? Promise.resolve();
const next = (async () => {
await previous.catch(() => {});
return task();
})();
this.chains.set(
providerId,
next.catch(() => {}),
);
return next;
}
async read(providerId: string): Promise<Credential | undefined> {
return this.credentials.get(providerId);
}
async list(): Promise<readonly CredentialInfo[]> {
return [...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type }));
}
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined> {
return this.enqueue(providerId, async () => {
const current = this.credentials.get(providerId);
const next = await fn(current);
if (next !== undefined) this.credentials.set(providerId, next);
return next ?? current;
});
}
delete(providerId: string): Promise<void> {
return this.enqueue(providerId, async () => {
this.credentials.delete(providerId);
});
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { ApiKeyAuth, OAuthAuth } from "./types.ts";
/**
* Standard api-key auth: a stored credential key wins, otherwise the first
* set env var resolves. Includes a `login` that prompts for the key.
* Providers with non-standard resolution (provider env, ambient files, IAM)
* write their own `ApiKeyAuth`.
*/
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth {
return {
name,
login: async (interaction) => {
const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` });
return { type: "api_key", key };
},
resolve: async ({ ctx, credential }) => {
if (credential?.key) {
return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
}
for (const envVar of envVars) {
const value = await ctx.env(envVar);
if (value) return { auth: { apiKey: value }, source: envVar };
}
return undefined;
},
};
}
/**
* Wraps a dynamically imported `OAuthAuth` so provider definitions can
* advertise OAuth without importing the implementation. The flow loads on
* first `login`/`refresh`/`toAuth` call; callers keep Node-only flow code out
* of bundles by loading through a bundler-opaque dynamic import (variable
* specifier, see the bedrock lazy wrapper).
*/
export function lazyOAuth(input: { name: string; loginLabel?: string; load: () => Promise<OAuthAuth> }): OAuthAuth {
let promise: Promise<OAuthAuth> | undefined;
const loaded = () => {
promise ??= input.load();
return promise;
};
return {
name: input.name,
loginLabel: input.loginLabel,
login: async (interaction) => (await loaded()).login(interaction),
refresh: async (credential) => (await loaded()).refresh(credential),
toAuth: async (credential) => (await loaded()).toAuth(credential),
};
}
+350
View File
@@ -0,0 +1,350 @@
/**
* Anthropic OAuth flow (Claude Pro/Max)
*
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
* 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 { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.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";
const CALLBACK_PORT = 53692;
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();
if (!value) return {};
try {
const url = new URL(value);
return {
code: url.searchParams.get("code") ?? undefined,
state: url.searchParams.get("state") ?? undefined,
};
} catch {
// not a URL
}
if (value.includes("#")) {
const [code, state] = value.split("#", 2);
return { code, state };
}
if (value.includes("code=")) {
const params = new URLSearchParams(value);
return {
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
};
}
return { code: value };
}
function formatErrorDetails(error: unknown): string {
if (error instanceof Error) {
const details: string[] = [`${error.name}: ${error.message}`];
const errorWithCode = error as Error & { code?: string; errno?: number | string; cause?: unknown };
if (errorWithCode.code) details.push(`code=${errorWithCode.code}`);
if (typeof errorWithCode.errno !== "undefined") details.push(`errno=${String(errorWithCode.errno)}`);
if (typeof error.cause !== "undefined") {
details.push(`cause=${formatErrorDetails(error.cause)}`);
}
if (error.stack) {
details.push(`stack=${error.stack}`);
}
return details.join("; ");
}
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>): Promise<string> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);
}
return responseBody;
}
async function exchangeAuthorizationCode(
code: string,
state: string,
verifier: string,
redirectUri: string,
): Promise<OAuthCredential> {
let responseBody: string;
try {
responseBody = await postJson(TOKEN_URL, {
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
state,
redirect_uri: redirectUri,
code_verifier: verifier,
});
} catch (error) {
throw new Error(
`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`,
);
}
let tokenData: { access_token: string; refresh_token: string; expires_in: number };
try {
tokenData = JSON.parse(responseBody) as { access_token: string; refresh_token: string; expires_in: number };
} catch (error) {
throw new Error(
`Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,
);
}
return {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
};
}
async function loginAnthropic(interaction: AuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const server = await startCallbackServer(verifier);
const manualAbort = new AbortController();
let code: string | undefined;
let state: string | undefined;
let manualInput: string | undefined;
let manualError: Error | undefined;
try {
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: 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. 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);
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);
} finally {
manualAbort.abort();
server.server.close();
}
}
/**
* Refresh Anthropic OAuth token
*/
async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredential> {
let responseBody: string;
try {
responseBody = await postJson(TOKEN_URL, {
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
});
} catch (error) {
throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);
}
let data: { access_token: string; refresh_token: string; expires_in: number; scope?: string };
try {
data = JSON.parse(responseBody) as {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
};
} catch (error) {
throw new Error(
`Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,
);
}
return {
type: "oauth",
refresh: data.refresh_token,
access: data.access_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
};
}
export const anthropicOAuth: OAuthAuth = {
name: "Anthropic (Claude Pro/Max)",
login: loginAnthropic,
refresh: (credential) => refreshAnthropicToken(credential.refresh),
async toAuth(credential) {
return { apiKey: credential.access };
},
};
@@ -0,0 +1,98 @@
const CANCEL_MESSAGE = "Login cancelled";
const TIMEOUT_MESSAGE = "Device flow timed out";
const SLOW_DOWN_TIMEOUT_MESSAGE =
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
const MINIMUM_INTERVAL_MS = 1000;
// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds.
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds.
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
type OAuthDeviceCodeIncompletePollResult =
| { status: "pending" }
| { status: "slow_down"; intervalSeconds?: number }
| { status: "failed"; message: string };
export type OAuthDeviceCodePollResult<T> = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T };
export type OAuthDeviceCodePollOptions<T> = {
intervalSeconds?: number;
expiresInSeconds?: number;
waitBeforeFirstPoll?: boolean;
poll: () => Promise<OAuthDeviceCodePollResult<T>>;
signal?: AbortSignal;
};
function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessage: string): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error(cancelMessage));
return;
}
const onAbort = () => {
clearTimeout(timeout);
reject(new Error(cancelMessage));
};
const timeout = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodePollOptions<T>): Promise<T> {
const deadline =
typeof options.expiresInSeconds === "number"
? Date.now() + options.expiresInSeconds * 1000
: Number.POSITIVE_INFINITY;
let intervalMs = Math.max(
MINIMUM_INTERVAL_MS,
Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000),
);
let slowDownResponses = 0;
if (options.waitBeforeFirstPoll) {
const remainingMs = deadline - Date.now();
if (remainingMs > 0) {
await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);
}
}
while (Date.now() < deadline) {
if (options.signal?.aborted) {
throw new Error(CANCEL_MESSAGE);
}
const result = await options.poll();
if (result.status === "complete") {
return result.value;
}
if (result.status === "failed") {
throw new Error(result.message);
}
if (result.status === "slow_down") {
slowDownResponses += 1;
// Use the server-provided interval when given (GitHub reports the new required minimum
// in `interval`); trusting only a client-tracked value risks polling early forever under
// WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds.
intervalMs =
typeof result.intervalSeconds === "number" &&
Number.isFinite(result.intervalSeconds) &&
result.intervalSeconds > 0
? Math.max(MINIMUM_INTERVAL_MS, Math.floor(result.intervalSeconds * 1000))
: Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
break;
}
await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);
}
throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE);
}
@@ -0,0 +1,379 @@
/**
* GitHub Copilot OAuth flow
*/
import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
const decode = (s: string) => atob(s);
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
const COPILOT_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.107.0",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "vscode-chat",
} as const;
const COPILOT_API_VERSION = "2026-06-01";
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
interval?: number;
expires_in: number;
};
type DeviceTokenSuccessResponse = {
access_token: string;
token_type?: string;
scope?: string;
};
type DeviceTokenErrorResponse = {
error: string;
error_description?: string;
interval?: number;
};
function normalizeDomain(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
return url.hostname;
} catch {
return null;
}
}
function getUrls(domain: string): {
deviceCodeUrl: string;
accessTokenUrl: string;
copilotTokenUrl: string;
} {
return {
deviceCodeUrl: `https://${domain}/login/device/code`,
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
};
}
/**
* Parse the proxy-ep from a Copilot token and convert to API base URL.
* Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...
* Returns API URL like https://api.individual.githubcopilot.com
*/
function getBaseUrlFromToken(token: string): string | null {
const match = token.match(/proxy-ep=([^;]+)/);
if (!match) return null;
const proxyHost = match[1];
// Convert proxy.xxx to api.xxx
const apiHost = proxyHost.replace(/^proxy\./, "api.");
return `https://${apiHost}`;
}
function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string {
// If we have a token, extract the base URL from proxy-ep
if (token) {
const urlFromToken = getBaseUrlFromToken(token);
if (urlFromToken) return urlFromToken;
}
// Fallback for enterprise or if token parsing fails
if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
return "https://api.individual.githubcopilot.com";
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function isSelectableCopilotModel(item: Record<string, unknown>): boolean {
const policy = asRecord(item.policy);
const capabilities = asRecord(item.capabilities);
const supports = asRecord(capabilities?.supports);
return item.model_picker_enabled === true && policy?.state !== "disabled" && supports?.tool_calls !== false;
}
function parseAvailableCopilotModelIds(raw: unknown): string[] {
const data = asRecord(raw)?.data;
if (!Array.isArray(data)) {
throw new Error("Invalid Copilot models response");
}
const ids: string[] = [];
for (const rawItem of data) {
const item = asRecord(rawItem);
const id = item?.id;
if (typeof id === "string" && item && isSelectableCopilotModel(item)) {
ids.push(id);
}
}
return ids;
}
async function fetchAvailableGitHubCopilotModelIds(copilotToken: string, enterpriseDomain?: string): Promise<string[]> {
const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain);
const raw = await fetchJson(`${baseUrl}/models`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${copilotToken}`,
...COPILOT_HEADERS,
"X-GitHub-Api-Version": COPILOT_API_VERSION,
},
signal: AbortSignal.timeout(5000),
});
return parseAvailableCopilotModelIds(raw);
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status} ${response.statusText}: ${text}`);
}
return response.json();
}
async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
const urls = getUrls(domain);
const data = await fetchJson(urls.deviceCodeUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
scope: "read:user",
}),
});
if (!data || typeof data !== "object") {
throw new Error("Invalid device code response");
}
const deviceCode = (data as Record<string, unknown>).device_code;
const userCode = (data as Record<string, unknown>).user_code;
const verificationUri = (data as Record<string, unknown>).verification_uri;
const interval = (data as Record<string, unknown>).interval;
const expiresIn = (data as Record<string, unknown>).expires_in;
if (
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
(interval !== undefined && typeof interval !== "number") ||
typeof expiresIn !== "number"
) {
throw new Error("Invalid device code response fields");
}
// The verification URI is opened in the user's browser and to prevent `open` from
// opening an executable or similar, we force it to be a URL.
let parsedUri: URL;
try {
parsedUri = new URL(verificationUri);
} catch {
throw new Error("Untrusted verification_uri in device code response");
}
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
throw new Error("Untrusted verification_uri in device code response");
}
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: parsedUri.href,
interval,
expires_in: expiresIn,
};
}
async function pollForGitHubAccessToken(
domain: string,
device: DeviceCodeResponse,
signal?: AbortSignal,
): Promise<string> {
const urls = getUrls(domain);
return pollOAuthDeviceCodeFlow<string>({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: device.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
return { status: "complete", value: (raw as DeviceTokenSuccessResponse).access_token };
}
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse;
if (error === "authorization_pending") {
return { status: "pending" };
}
if (error === "slow_down") {
return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined };
}
const descriptionSuffix = description ? `: ${description}` : "";
return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` };
}
return { status: "failed", message: "Invalid device token response" };
},
});
}
async function refreshGitHubCopilotAccessToken(
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredential> {
const domain = enterpriseDomain || "github.com";
const urls = getUrls(domain);
const raw = await fetchJson(urls.copilotTokenUrl, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${refreshToken}`,
...COPILOT_HEADERS,
},
});
if (!raw || typeof raw !== "object") {
throw new Error("Invalid Copilot token response");
}
const token = (raw as Record<string, unknown>).token;
const expiresAt = (raw as Record<string, unknown>).expires_at;
if (typeof token !== "string" || typeof expiresAt !== "number") {
throw new Error("Invalid Copilot token response fields");
}
return {
type: "oauth",
refresh: refreshToken,
access: token,
expires: expiresAt * 1000 - 5 * 60 * 1000,
enterpriseUrl: enterpriseDomain,
};
}
/**
* Refresh GitHub Copilot token
*/
async function refreshGitHubCopilotToken(refreshToken: string, enterpriseDomain?: string): Promise<OAuthCredential> {
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain);
return {
...credentials,
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain),
};
}
/**
* Enable a model for the user's GitHub Copilot account.
* This is required for some models (like Claude, Grok) before they can be used.
*/
async function enableGitHubCopilotModel(token: string, modelId: string, enterpriseDomain?: string): Promise<boolean> {
const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
const url = `${baseUrl}/models/${modelId}/policy`;
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...COPILOT_HEADERS,
"openai-intent": "chat-policy",
"x-interaction-type": "chat-policy",
},
body: JSON.stringify({ state: "enabled" }),
});
return response.ok;
} catch {
return false;
}
}
/**
* Enable all known GitHub Copilot models that may require policy acceptance.
* Called after successful login to ensure all models are available.
*/
async function enableAllGitHubCopilotModels(token: string, enterpriseDomain?: string): Promise<void> {
const models = Object.values(GITHUB_COPILOT_MODELS);
await Promise.all(
models.map(async (model) => {
await enableGitHubCopilotModel(token, model.id, enterpriseDomain);
}),
);
}
async function loginGitHubCopilot(interaction: AuthInteraction): Promise<OAuthCredential> {
const input = await interaction.prompt({
type: "text",
message: "GitHub Enterprise URL/domain (blank for github.com)",
placeholder: "company.ghe.com",
});
if (interaction.signal?.aborted) throw new Error("Login cancelled");
const trimmed = input.trim();
const enterpriseDomain = normalizeDomain(input);
if (trimmed && !enterpriseDomain) throw new Error("Invalid GitHub Enterprise URL/domain");
const domain = enterpriseDomain || "github.com";
const device = await startDeviceFlow(domain);
interaction.notify({
type: "device_code",
userCode: device.user_code,
verificationUri: device.verification_uri,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
const githubAccessToken = await pollForGitHubAccessToken(domain, device, interaction.signal);
const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined);
interaction.notify({ type: "progress", message: "Enabling models..." });
await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined);
return {
...credentials,
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined),
};
}
function copilotEnterpriseDomain(credential: OAuthCredential): string | undefined {
const enterpriseUrl = credential.enterpriseUrl;
if (typeof enterpriseUrl !== "string" || !enterpriseUrl) return undefined;
return normalizeDomain(enterpriseUrl) ?? undefined;
}
export const githubCopilotOAuth: OAuthAuth = {
name: "GitHub Copilot",
login: loginGitHubCopilot,
refresh: (credential) => refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential)),
/** Derive the credential-specific proxy endpoint for each request. */
async toAuth(credential) {
return {
apiKey: credential.access,
baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)),
};
},
};
@@ -0,0 +1,302 @@
/**
* Kimi Code (subscription) OAuth flow
*
* RFC 8628 device authorization grant against https://auth.kimi.com with JSON
* responses. The access token authenticates requests to
* https://api.kimi.com/coding as an `Authorization: Bearer` header.
*/
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
const REQUEST_TIMEOUT_MS = 30 * 1000;
const REFRESH_MAX_RETRIES = 3;
type DeviceAuthorization = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete: string;
intervalSeconds: number;
expiresInSeconds: number;
};
type TokenResponse = {
access: string;
refresh: string;
expires: number;
};
function getOauthHost(): string {
const override = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST");
return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, "");
}
function requestSignal(signal?: AbortSignal): AbortSignal {
return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(signal ? [signal] : [])]);
}
function formUrlEncode(fields: Record<string, string>): string {
return new URLSearchParams(fields).toString();
}
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;
}
}
/** 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;
}
}
async function startDeviceAuthorization(oauthHost: string, signal?: AbortSignal): Promise<DeviceAuthorization> {
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: formUrlEncode({ client_id: CLIENT_ID }),
signal: requestSignal(signal),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Kimi Code device authorization failed with status ${response.status}${text ? `: ${text}` : ""}`);
}
const json = await readJson(response);
const deviceCode = json?.device_code;
const userCode = json?.user_code;
const verificationUri = json?.verification_uri;
const verificationUriComplete = json?.verification_uri_complete;
if (
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof verificationUriComplete !== "string" ||
!trustedHttpUrl(verificationUriComplete) ||
!trustedHttpUrl(verificationUri)
) {
throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`);
}
const interval = json?.interval;
const expiresIn = json?.expires_in;
return {
deviceCode,
userCode,
verificationUri,
verificationUriComplete,
intervalSeconds:
typeof interval === "number" && Number.isFinite(interval) && interval > 0
? interval
: DEFAULT_POLL_INTERVAL_SECONDS,
expiresInSeconds:
typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0
? expiresIn
: DEVICE_CODE_TIMEOUT_SECONDS,
};
}
function parseTokenResponse(json: Record<string, unknown> | null, operation: string): TokenResponse {
const accessToken = json?.access_token;
const refreshToken = json?.refresh_token;
const expiresIn = json?.expires_in;
if (
typeof accessToken !== "string" ||
!accessToken ||
typeof refreshToken !== "string" ||
!refreshToken ||
typeof expiresIn !== "number" ||
!Number.isFinite(expiresIn) ||
expiresIn <= 0
) {
throw new Error(`Kimi Code token ${operation} response missing fields: ${JSON.stringify(json)}`);
}
return {
access: accessToken,
refresh: refreshToken,
expires: Date.now() + expiresIn * 1000,
};
}
async function pollForToken(
oauthHost: string,
device: DeviceAuthorization,
signal?: AbortSignal,
): Promise<TokenResponse> {
return pollOAuthDeviceCodeFlow<TokenResponse>({
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const response = await fetch(`${oauthHost}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: formUrlEncode({
client_id: CLIENT_ID,
device_code: device.deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
signal: requestSignal(signal),
});
if (response.status >= 500) {
const text = await response.text().catch(() => "");
return {
status: "failed",
message: `Kimi Code device token request failed with status ${response.status}${text ? `: ${text}` : ""}`,
};
}
const json = await readJson(response);
if (response.ok && typeof json?.access_token === "string") {
try {
return { status: "complete", value: parseTokenResponse(json, "poll") };
} catch (error) {
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
}
}
const error = json?.error;
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
if (error === "authorization_pending") {
return { status: "pending" };
}
if (error === "slow_down") {
const interval = json?.interval;
return {
status: "slow_down",
intervalSeconds: typeof interval === "number" && interval > 0 ? interval : undefined,
};
}
if (error === "expired_token") {
return { status: "failed", message: "Kimi Code device authorization expired. Please restart login." };
}
if (error === "access_denied") {
return { status: "failed", message: "Kimi Code login was denied." };
}
return {
status: "failed",
message: `Kimi Code device token request failed (status ${response.status})${typeof error === "string" ? `: ${error}${description}` : ""}`,
};
},
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableRefreshFailure(response: Response): boolean {
return response.status === 429 || response.status >= 500;
}
async function refreshToken(
oauthHost: string,
refreshTokenValue: string,
signal?: AbortSignal,
): Promise<TokenResponse> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= REFRESH_MAX_RETRIES; attempt++) {
if (attempt > 0) {
await sleep(1000 * 2 ** (attempt - 1));
}
if (signal?.aborted) {
throw new Error("Kimi Code token refresh aborted");
}
let response: Response;
try {
response = await fetch(`${oauthHost}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: formUrlEncode({
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token: refreshTokenValue,
}),
signal: requestSignal(signal),
});
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
continue;
}
const json = await readJson(response);
if (response.ok) {
return parseTokenResponse(json, "refresh");
}
// Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") {
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
throw new Error(`Kimi Code token refresh unauthorized (status ${response.status})${description}`);
}
if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) {
lastError = new Error(`Kimi Code token refresh failed with status ${response.status}`);
continue;
}
const text = JSON.stringify(json);
throw new Error(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`);
}
throw lastError ?? new Error("Kimi Code token refresh failed");
}
async function loginKimiCoding(interaction: AuthInteraction): Promise<OAuthCredential> {
const oauthHost = getOauthHost();
const device = await startDeviceAuthorization(oauthHost, interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.userCode,
verificationUri: device.verificationUriComplete,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
});
const token = await pollForToken(oauthHost, device, interaction.signal);
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
}
export const kimiCodingOAuth: OAuthAuth = {
name: "Kimi Code (subscription)",
loginLabel: "Sign in with Kimi Code",
login: loginKimiCoding,
refresh: async (credential, signal) => {
const token = await refreshToken(getOauthHost(), credential.refresh, signal);
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
},
async toAuth(credential) {
return { headers: { Authorization: `Bearer ${credential.access}` } };
},
};
+68
View File
@@ -0,0 +1,68 @@
import type { OAuthAuth } from "../types.ts";
/**
* Loads an OAuth flow module through a variable specifier so bundlers cannot
* follow the import into Node-only flow code (`node:http` callback servers,
* `node:crypto` PKCE). The `.ts`/`.js` rewrite keeps the trick working from
* both source and built output.
*/
const importOAuthModule = (specifier: string): Promise<unknown> => {
const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;
return import(runtimeSpecifier);
};
type OAuthFlowLoaders = {
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
xai: () => OAuthAuth | Promise<OAuthAuth>;
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
};
let bundledLoaders: OAuthFlowLoaders | undefined;
/** Registers statically bundled OAuth flows for standalone Bun binaries. */
export function registerBundledOAuthFlowLoaders(loaders: OAuthFlowLoaders): void {
bundledLoaders = loaders;
}
export const loadAnthropicOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.anthropic();
return ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth;
};
export const loadOpenAICodexOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openaiCodex();
return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth;
};
export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.githubCopilot();
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
};
export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openrouter();
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
};
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.kimiCoding();
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
};
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.xai();
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
};
export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.radius(options);
return (
(await importOAuthModule("./radius.ts")) as {
createRadiusOAuth: (input: { name: string; gateway: string }) => OAuthAuth;
}
).createRadiusOAuth(options);
};
+109
View File
@@ -0,0 +1,109 @@
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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,
});
}
@@ -0,0 +1,538 @@
/**
* OpenAI Codex (ChatGPT OAuth) flow
*
* NOTE: This module uses Node.js crypto and http 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 { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTH_BASE_URL = "https://auth.openai.com";
const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`;
const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`;
const REDIRECT_URI = "http://localhost:1455/auth/callback";
const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`;
const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`;
const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`;
const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`;
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser";
const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code";
const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
type OAuthToken = { access: string; refresh: string; expires: number };
type TokenOperation = "exchange" | "refresh";
function getCallbackHost(): string {
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
}
type DeviceAuthInfo = {
deviceAuthId: string;
userCode: string;
intervalSeconds: number;
};
type DeviceTokenSuccess = {
authorizationCode: string;
codeVerifier: string;
};
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string;
};
[key: string]: unknown;
};
function createState(): string {
if (!_randomBytes) {
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
}
return _randomBytes(16).toString("hex");
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
if (!value) return {};
try {
const url = new URL(value);
return {
code: url.searchParams.get("code") ?? undefined,
state: url.searchParams.get("state") ?? undefined,
};
} catch {
// not a URL
}
if (value.includes("#")) {
const [code, state] = value.split("#", 2);
return { code, state };
}
if (value.includes("code=")) {
const params = new URLSearchParams(value);
return {
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
};
}
return { code: value };
}
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = parts[1] ?? "";
const decoded = atob(payload);
return JSON.parse(decoded) as JwtPayload;
} catch {
return null;
}
}
async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise<Response> {
try {
return await fetch(input, init);
} catch (error) {
if (init.signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
}
async function readTokenResponse(response: Response, operation: TokenOperation): Promise<OAuthToken> {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${text || response.statusText}`);
}
const rawJson = await response.json();
const json = rawJson as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
} | null;
if (!json?.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
throw new Error(`OpenAI Codex token ${operation} response missing fields: ${JSON.stringify(json)}`);
}
return {
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
redirectUri: string = REDIRECT_URI,
signal?: AbortSignal,
): Promise<OAuthToken> {
const response = await fetchWithLoginCancellation(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
signal,
});
return readTokenResponse(response, "exchange");
}
async function refreshAccessToken(refreshToken: string): Promise<OAuthToken> {
let response: Response;
try {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
} catch (error) {
throw new Error(`OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`);
}
return readTokenResponse(response, "refresh");
}
async function startOpenAICodexDeviceAuth(signal?: AbortSignal): Promise<DeviceAuthInfo> {
const response = await fetchWithLoginCancellation(DEVICE_USER_CODE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: CLIENT_ID }),
signal,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(
"OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.",
);
}
const responseBody = await response.text().catch(() => "");
throw new Error(
`OpenAI Codex device code request failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`,
);
}
const rawJson = await response.json();
const json = rawJson as {
device_auth_id?: string;
user_code?: string;
interval?: number | string;
} | null;
const intervalSeconds = typeof json?.interval === "string" ? Number(json.interval.trim()) : json?.interval;
if (
!json?.device_auth_id ||
!json.user_code ||
typeof intervalSeconds !== "number" ||
!Number.isFinite(intervalSeconds) ||
intervalSeconds < 0
) {
throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`);
}
return {
deviceAuthId: json.device_auth_id,
userCode: json.user_code,
intervalSeconds,
};
}
async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal?: AbortSignal): Promise<DeviceTokenSuccess> {
return pollOAuthDeviceCodeFlow<DeviceTokenSuccess>({
intervalSeconds: device.intervalSeconds,
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
signal,
poll: async () => {
const response = await fetchWithLoginCancellation(DEVICE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_auth_id: device.deviceAuthId,
user_code: device.userCode,
}),
signal,
});
if (response.ok) {
const rawJson = await response.json();
const json = rawJson as { authorization_code?: string; code_verifier?: string } | null;
if (!json?.authorization_code || !json.code_verifier) {
return {
status: "failed",
message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}`,
};
}
return {
status: "complete",
value: { authorizationCode: json.authorization_code, codeVerifier: json.code_verifier },
};
}
if (response.status === 403 || response.status === 404) {
return { status: "pending" };
}
const responseBody = await response.text().catch(() => "");
let errorCode: unknown;
try {
const json = JSON.parse(responseBody) as { error?: string | { code?: string } } | null;
const error = json?.error;
errorCode = typeof error === "object" ? error?.code : error;
} catch {}
if (errorCode === "deviceauth_authorization_pending") {
return { status: "pending" };
}
if (errorCode === "slow_down") {
return { status: "slow_down" };
}
return {
status: "failed",
message: `OpenAI Codex device auth failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`,
};
},
});
}
async function createAuthorizationFlow(
originator: string = "pi",
): Promise<{ verifier: string; state: string; url: string }> {
const { verifier, challenge } = await generatePKCE();
const state = createState();
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPE);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("id_token_add_organizations", "true");
url.searchParams.set("codex_cli_simplified_flow", "true");
url.searchParams.set("originator", originator);
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];
const accountId = auth?.chatgpt_account_id;
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}
function credentialsFromToken(token: OAuthToken): OAuthCredential {
const accountId = getAccountId(token.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
type: "oauth",
access: token.access,
refresh: token.refresh,
expires: token.expires,
accountId,
};
}
async function exchangeAuthorizationCodeForCredentials(
code: string,
verifier: string,
redirectUri: string,
signal?: AbortSignal,
): Promise<OAuthCredential> {
return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal));
}
async function loginOpenAICodexDeviceCode(interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await startOpenAICodexDeviceAuth(interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.userCode,
verificationUri: DEVICE_VERIFICATION_URI,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
});
const code = await pollOpenAICodexDeviceAuth(device, interaction.signal);
return exchangeAuthorizationCodeForCredentials(
code.authorizationCode,
code.codeVerifier,
DEVICE_REDIRECT_URI,
interaction.signal,
);
}
async function loginOpenAICodex(interaction: AuthInteraction): Promise<OAuthCredential> {
const { verifier, state, url } = await createAuthorizationFlow();
const server = await startLocalOAuthServer(state);
const manualAbort = new AbortController();
let code: string | undefined;
let manualCode: string | undefined;
let manualError: Error | undefined;
interaction.notify({
type: "auth_url",
url,
instructions: "A browser window should open. Complete login to finish.",
});
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);
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);
} finally {
manualAbort.abort();
server.close();
}
}
/**
* Refresh OpenAI Codex OAuth token
*/
async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredential> {
return credentialsFromToken(await refreshAccessToken(refreshToken));
}
export const openaiCodexOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT Plus/Pro)",
async login(interaction) {
const method = await interaction.prompt({
type: "select",
message: "Select OpenAI Codex login method:",
options: [
{ id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
{ id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" },
],
});
if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) {
return loginOpenAICodexDeviceCode(interaction);
}
if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown OpenAI Codex login method: ${method}`);
}
return loginOpenAICodex(interaction);
},
refresh: (credential) => refreshOpenAICodexToken(credential.refresh),
async toAuth(credential) {
return { apiKey: credential.access };
},
};
+311
View File
@@ -0,0 +1,311 @@
/**
* OpenRouter OAuth PKCE flow.
*
* OpenRouter exchanges an authorization code for a permanent, user-controlled
* API key rather than an expiring access/refresh token pair. The callback is
* handled by a one-shot loopback server on an ephemeral port, raced against a
* 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.
* 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 { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const AUTHORIZE_URL = "https://openrouter.ai/auth";
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
function getCallbackHost(): string {
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
}
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;
try {
return new URL(value).searchParams.get("code") ?? undefined;
} catch {
// not a URL
}
if (value.includes("code=")) {
return new URLSearchParams(value).get("code") ?? undefined;
}
return value;
}
function errorDetail(body: JsonObject): string | undefined {
if (typeof body.error_description === "string") return body.error_description;
if (typeof body.message === "string") return body.message;
if (typeof body.error === "string") return body.error;
if (body.error && typeof body.error === "object" && !Array.isArray(body.error)) {
const message = (body.error as JsonObject).message;
if (typeof message === "string") return message;
}
return undefined;
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
signal?: AbortSignal,
): Promise<OAuthCredential> {
if (signal?.aborted) throw new Error("Login cancelled");
const controller = new AbortController();
const onAbort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", onAbort, { once: true });
const timeout = setTimeout(
() => controller.abort(new Error("OpenRouter OAuth token exchange timed out")),
TOKEN_EXCHANGE_TIMEOUT_MS,
);
let response: Response;
let body: JsonObject = {};
try {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
signal: controller.signal,
});
try {
const parsed = (await response.json()) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as JsonObject;
} catch {
if (response.ok) throw new Error("OpenRouter OAuth returned invalid JSON");
}
} catch (error) {
if (signal?.aborted) throw new Error("Login cancelled");
if (controller.signal.aborted) throw new Error("OpenRouter OAuth token exchange timed out");
throw error;
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
}
if (!response.ok) {
const detail = errorDetail(body);
throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
}
if (typeof body.key !== "string" || body.key.length === 0) {
throw new Error('OpenRouter OAuth response carries no "key"');
}
return {
type: "oauth",
access: body.key,
refresh: "",
expires: Number.MAX_SAFE_INTEGER,
};
}
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: AuthInteraction): 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;
try {
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.search = new URLSearchParams({
callback_url: callback.callbackUrl,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
interaction.notify({
type: "progress",
message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
});
interaction.notify({
type: "auth_url",
url: authorizeUrl.toString(),
instructions:
"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;
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();
}
}
export const openRouterOAuth: OAuthAuth = {
name: "OpenRouter OAuth",
loginLabel: "Sign in with OpenRouter",
login: loginOpenRouter,
async refresh(credential) {
return credential;
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};
+34
View File
@@ -0,0 +1,34 @@
/**
* PKCE utilities using Web Crypto API.
* Works in both Node.js 20+ and browsers.
*/
/**
* Encode bytes as base64url string.
*/
function base64urlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
/**
* Generate PKCE code verifier and challenge.
* Uses Web Crypto API for cross-platform compatibility.
*/
export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
// Generate random verifier
const verifierBytes = new Uint8Array(32);
crypto.getRandomValues(verifierBytes);
const verifier = base64urlEncode(verifierBytes);
// Compute SHA-256 challenge
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
return { verifier, challenge };
}
+408
View File
@@ -0,0 +1,408 @@
/**
* Radius gateway OAuth flow.
*
* Radius is a pi-messages gateway. OAuth client APIs live on the configured
* 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.
* 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 { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.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";
const CALLBACK_PORT = 1456;
const CALLBACK_PATH = "/oauth/callback";
const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
const TOKEN_EXPIRY_SKEW_MS = 60_000;
const LOGIN_METHOD_BROWSER = "browser";
const LOGIN_METHOD_DEVICE_CODE = "device-code";
const OAUTH_CLIENT_ID = "pi-gateway";
const OAUTH_SCOPE = "gateway offline_access";
const OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
type RadiusOAuthDiscovery = {
authorizationEndpoint: string;
};
type DeviceAuthorizationResponse = {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval?: number;
};
async function loadRadiusOAuthDiscovery(gateway: string): Promise<RadiusOAuthDiscovery> {
const response = await fetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Error(
`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`,
);
}
const discovery = (await response.json()) as Partial<RadiusOAuthDiscovery>;
if (typeof discovery.authorizationEndpoint !== "string") {
throw new Error(`Invalid Radius OAuth config from ${gateway}`);
}
return { authorizationEndpoint: discovery.authorizationEndpoint };
}
class OAuthResponseError extends Error {
readonly status: number;
readonly oauthError?: string;
constructor(status: number, oauthError: string | undefined, description: string | undefined, message: string) {
const detail = oauthError
? description
? `${oauthError}: ${description}`
: oauthError
: description || String(status);
super(`${message}: ${detail}`);
this.status = status;
this.oauthError = oauthError;
}
}
async function readOAuthResponseError(response: Response, message: string): Promise<OAuthResponseError> {
const text = await response.text().catch(() => "");
let oauthError: string | undefined;
let description: string | undefined;
if (text) {
try {
const data = JSON.parse(text) as { error?: unknown; error_description?: unknown };
oauthError = typeof data.error === "string" ? data.error : undefined;
description = typeof data.error_description === "string" ? data.error_description : undefined;
} catch {
description = text;
}
}
return new OAuthResponseError(response.status, oauthError, description, message);
}
async function requestOAuthToken(
gateway: string,
body: URLSearchParams,
signal?: AbortSignal,
): Promise<OAuthCredential> {
let response: Response;
try {
response = await fetch(new URL("/v1/oauth/token", gateway), {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body,
signal,
});
} catch (error) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
if (!response.ok) {
throw await readOAuthResponseError(response, "Radius OAuth token request failed");
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
};
return {
type: "oauth",
access: data.access_token,
refresh: data.refresh_token,
expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,
scope: data.scope,
};
}
type OAuthCallbackServer = {
waitForCode(): Promise<string | null>;
close(): void;
};
function startOAuthCallbackServer(
expectedState: string,
signal: AbortSignal | undefined,
): 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,
interaction: AuthInteraction,
): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
const authorizeUrl = new URL(authorizationEndpoint);
authorizeUrl.search = new URLSearchParams({
response_type: "code",
client_id: OAUTH_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: OAUTH_SCOPE,
code_challenge: challenge,
code_challenge_method: "S256",
handoff: "url",
state,
}).toString();
const callbackServer = await startOAuthCallbackServer(state, interaction.signal);
interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` });
interaction.notify({
type: "auth_url",
url: authorizeUrl.toString(),
instructions: "Continue in your browser.",
});
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,
);
} finally {
callbackServer.close();
}
}
async function requestDeviceAuthorization(
gateway: string,
signal: AbortSignal | undefined,
): Promise<DeviceAuthorizationResponse> {
let response: Response;
try {
response = await fetch(new URL("/v1/oauth/device", gateway), {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
if (!response.ok) {
throw await readOAuthResponseError(response, "Radius OAuth device authorization failed");
}
const data = (await response.json()) as Partial<DeviceAuthorizationResponse>;
if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
throw new Error("Radius OAuth device authorization response is missing required fields");
}
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri,
expires_in: data.expires_in,
interval: data.interval,
};
}
async function loginWithDeviceCode(gateway: string, interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await requestDeviceAuthorization(gateway, interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.user_code,
verificationUri: device.verification_uri,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
return pollOAuthDeviceCodeFlow<OAuthCredential>({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
signal: interaction.signal,
poll: async () => {
try {
const credentials = await requestOAuthToken(
gateway,
new URLSearchParams({
grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE,
client_id: OAUTH_CLIENT_ID,
device_code: device.device_code,
}),
interaction.signal,
);
return { status: "complete", value: credentials };
} catch (error) {
if (!(error instanceof OAuthResponseError)) {
throw error;
}
switch (error.oauthError) {
case "authorization_pending":
return { status: "pending" };
case "slow_down":
return { status: "slow_down" };
case "expired_token":
return { status: "failed", message: "Device authorization expired." };
case "access_denied":
return { status: "failed", message: "Device authorization was denied." };
default:
throw error;
}
}
},
});
}
export interface RadiusOAuthOptions {
name: string;
gateway: string;
}
export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
const gateway = normalizeRadiusGatewayUrl(options.gateway);
return {
name: options.name,
async login(interaction): Promise<OAuthCredential> {
const loginMethod = await interaction.prompt({
type: "select",
message: `Sign in to ${options.name}:`,
options: [
{ id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" },
{
id: LOGIN_METHOD_DEVICE_CODE,
label: "Sign in with device code (when signing in from another device)",
},
],
});
if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
return loginWithDeviceCode(gateway, interaction);
}
if (loginMethod === LOGIN_METHOD_BROWSER) {
const discovery = await loadRadiusOAuthDiscovery(gateway);
return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction);
}
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
},
async refresh(credential, signal): Promise<OAuthCredential> {
const refreshed = await requestOAuthToken(
gateway,
new URLSearchParams({
grant_type: "refresh_token",
client_id: OAUTH_CLIENT_ID,
refresh_token: credential.refresh,
}),
signal,
);
return refreshed;
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};
}
+238
View File
@@ -0,0 +1,238 @@
/**
* xAI OAuth device-code flow.
*/
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access";
const XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
const XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token";
// Refresh slightly before the reported expiry to avoid using a token that dies mid-request.
const REFRESH_SKEW_MS = 5 * 60 * 1000;
const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
type JsonObject = Record<string, unknown>;
type OAuthHttpResponse = {
ok: boolean;
status: number;
body: JsonObject;
};
type XaiDeviceCode = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
intervalSeconds?: number;
expiresInSeconds: number;
};
function requiredString(body: JsonObject, field: string): string {
const value = body[field];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Invalid xAI OAuth response field: ${field}`);
}
return value;
}
function positiveNumber(body: JsonObject, field: string): number {
const value = body[field];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`Invalid xAI OAuth response field: ${field}`);
}
return value;
}
// The verification URI is opened in the user's browser; force it to be an https URL
// so a malicious response cannot make `open` launch something else.
function validateVerificationUri(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("Untrusted verification URI in xAI OAuth response");
}
if (url.protocol !== "https:") {
throw new Error("Untrusted verification URI in xAI OAuth response");
}
return url.href;
}
async function postForm(url: string, fields: Record<string, string>, signal?: AbortSignal): Promise<OAuthHttpResponse> {
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(fields),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
let body: JsonObject;
try {
const parsed = (await response.json()) as unknown;
body = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as JsonObject) : {};
} catch {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw new Error(`xAI OAuth returned invalid JSON (HTTP ${response.status})`);
}
return {
ok: response.ok,
status: response.status,
body,
};
}
function requestFailure(action: string, response: OAuthHttpResponse): Error {
const error = typeof response.body.error === "string" ? response.body.error : undefined;
const description =
typeof response.body.error_description === "string" ? response.body.error_description : undefined;
const detail = [error, description].filter(Boolean).join(": ");
return new Error(`xAI OAuth ${action} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
}
function parseDeviceCode(body: JsonObject): XaiDeviceCode {
// RFC 8628 allows interval 0 (no minimum wait); fall back to the poller's
// default instead of failing on non-positive or malformed values.
const interval = body.interval;
const intervalSeconds =
typeof interval === "number" && Number.isFinite(interval) && interval > 0 ? interval : undefined;
const verificationUriComplete =
typeof body.verification_uri_complete === "string" && body.verification_uri_complete.length > 0
? validateVerificationUri(body.verification_uri_complete)
: undefined;
return {
deviceCode: requiredString(body, "device_code"),
userCode: requiredString(body, "user_code"),
verificationUri: validateVerificationUri(requiredString(body, "verification_uri")),
verificationUriComplete,
intervalSeconds,
expiresInSeconds: positiveNumber(body, "expires_in"),
};
}
function credentialsFromTokenResponse(body: JsonObject, previousRefreshToken?: string): OAuthCredential {
const access = requiredString(body, "access_token");
// xAI may omit refresh_token on refresh when the token is not rotated.
const refresh =
body.refresh_token === undefined && previousRefreshToken
? previousRefreshToken
: requiredString(body, "refresh_token");
const expiresInSeconds =
body.expires_in === undefined ? DEFAULT_TOKEN_LIFETIME_SECONDS : positiveNumber(body, "expires_in");
return {
type: "oauth",
access,
refresh,
expires: Date.now() + expiresInSeconds * 1000 - REFRESH_SKEW_MS,
};
}
async function requestDeviceCode(signal?: AbortSignal): Promise<XaiDeviceCode> {
const response = await postForm(
XAI_DEVICE_CODE_URL,
{
client_id: XAI_CLIENT_ID,
scope: XAI_SCOPE,
referrer: "pi",
},
signal,
);
if (!response.ok) {
throw requestFailure("device authorization", response);
}
return parseDeviceCode(response.body);
}
async function pollForTokens(device: XaiDeviceCode, signal?: AbortSignal): Promise<OAuthCredential> {
return pollOAuthDeviceCodeFlow<OAuthCredential>({
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const response = await postForm(
XAI_TOKEN_URL,
{
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: XAI_CLIENT_ID,
device_code: device.deviceCode,
},
signal,
);
if (response.ok) {
return { status: "complete", value: credentialsFromTokenResponse(response.body) };
}
const error = response.body.error;
if (error === "authorization_pending") {
return { status: "pending" };
}
if (error === "slow_down") {
const interval = response.body.interval;
return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined };
}
if (error === "access_denied" || error === "authorization_denied") {
return { status: "failed", message: "xAI device authorization was denied" };
}
if (error === "expired_token") {
return { status: "failed", message: "xAI device code expired" };
}
return { status: "failed", message: requestFailure("device token polling", response).message };
},
});
}
async function loginXai(interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await requestDeviceCode(interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.userCode,
verificationUri: device.verificationUriComplete ?? device.verificationUri,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
});
return pollForTokens(device, interaction.signal);
}
async function refreshXaiToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredential> {
const response = await postForm(
XAI_TOKEN_URL,
{
grant_type: "refresh_token",
client_id: XAI_CLIENT_ID,
refresh_token: refreshToken,
},
signal,
);
if (!response.ok) {
throw requestFailure("token refresh", response);
}
return credentialsFromTokenResponse(response.body, refreshToken);
}
export const xaiOAuth: OAuthAuth = {
name: "xAI (Grok/X subscription)",
loginLabel: "Sign in with SuperGrok or X Premium",
login: loginXai,
refresh: (credential, signal) => refreshXaiToken(credential.refresh, signal),
async toAuth(credential) {
return { apiKey: credential.access };
},
};
+166
View File
@@ -0,0 +1,166 @@
import type { ProviderEnv } from "../types.ts";
import { formatThrownValue } from "../utils/diagnostics.ts";
import type {
ApiKeyAuth,
ApiKeyCredential,
AuthContext,
AuthResult,
Credential,
CredentialStore,
OAuthAuth,
OAuthCredential,
ProviderAuth,
} from "./types.ts";
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
export interface AuthResolutionOverrides {
apiKey?: string;
env?: ProviderEnv;
/** Require this much remaining OAuth-token validity; defaults to five minutes. */
minOAuthValidityMs?: number;
}
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.
* 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.
*/
export async function resolveProviderAuth(
provider: { id: string; auth: ProviderAuth },
credentials: CredentialStore,
authContext: AuthContext,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, {
type: "api_key",
key: overrides.apiKey,
env: overrides.env,
});
}
const stored = await readCredential(credentials, provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
return resolveStoredOAuth(
credentials,
provider.id,
provider.auth.oauth,
stored,
overrides?.minOAuthValidityMs,
);
}
if (stored.type === "api_key" && provider.auth.apiKey) {
const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored;
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential);
}
return undefined;
}
// Ambient (env vars, AWS profiles, ADC files).
return provider.auth.apiKey
? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined)
: undefined;
}
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext {
return {
env: async (name) => env[name] || (await base.env(name)),
fileExists: (path) => base.fileExists(path),
};
}
const DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000;
/**
* 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.
*/
async function resolveStoredOAuth(
credentials: CredentialStore,
providerId: string,
oauth: OAuthAuth,
stored: OAuthCredential,
minOAuthValidityMs?: number,
): Promise<AuthResult | undefined> {
const minimumValidityMs = Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS, minOAuthValidityMs ?? 0);
const expiresSoon = (credential: OAuthCredential) => Date.now() + minimumValidityMs >= credential.expires;
let credential = stored;
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 {
return await oauth.refresh(current);
} catch (error) {
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
}
});
} 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
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
// require the requested minimum after the refresh.
if (minOAuthValidityMs !== undefined && expiresSoon(credential)) {
throw new ModelsError("oauth", `OAuth refresh returned a token that expires too soon for ${providerId}`);
}
}
try {
return { auth: await oauth.toAuth(credential), source: "OAuth" };
} catch (error) {
throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error });
}
}
async function resolveApiKey(
authContext: AuthContext,
apiKey: ApiKeyAuth,
providerId: string,
credential: ApiKeyCredential | undefined,
): Promise<AuthResult | undefined> {
try {
return await apiKey.resolve({ ctx: authContext, credential });
} catch (error) {
throw new ModelsError("auth", `API key auth failed for provider ${providerId}`, { cause: error });
}
}
async function readCredential(credentials: CredentialStore, providerId: string): Promise<Credential | undefined> {
try {
return await credentials.read(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
}
}
+220
View File
@@ -0,0 +1,220 @@
import type { ProviderEnv, ProviderHeaders } from "../types.ts";
/**
* Request auth for a single model request. If a value cannot be expressed as
* `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth.
*/
export interface ModelAuth {
apiKey?: string;
headers?: ProviderHeaders;
baseUrl?: string;
}
/**
* Stored api-key credential. `env` holds provider-scoped environment/config
* values such as Cloudflare account/gateway ids.
*/
export interface ApiKeyCredential {
type: "api_key";
key?: string;
env?: ProviderEnv;
}
/** OAuth token data returned by extension compatibility flows. */
export interface OAuthCredentials {
refresh: string;
access: string;
expires: number;
[key: string]: unknown;
}
/** Stored canonical OAuth credential. */
export interface OAuthCredential extends OAuthCredentials {
type: "oauth";
}
/** One type-tagged credential per provider — the shape of today's auth.json. */
export type Credential = ApiKeyCredential | OAuthCredential;
/** Non-secret credential metadata for account/status enumeration. */
export interface CredentialInfo {
providerId: string;
type: Credential["type"];
}
/**
* App-owned credential storage, keyed by `Provider.id`, one credential per
* provider. `modify` is the only write path, so every mutation is a
* serialized read-modify-write; `Models.getAuth()` runs OAuth refresh inside
* `modify` so concurrent requests cannot double-refresh a rotated token. The
* app persists a credential after login via
* `modify(provider.id, async () => credential)`. Login/logout orchestration
* is app-owned.
*
* Error semantics: `read` resolves `undefined` for missing entries. Methods
* reject only on storage failure; `Models` wraps such rejections in
* `ModelsError` with code "auth". Best-effort stores that serve an in-memory
* view and record persistence errors internally (like coding-agent's
* AuthStorage) are valid implementations.
*/
export interface CredentialStore {
/**
* Read the stored credential, possibly expired. Display/status use;
* resolved request auth comes from `Models.getAuth()`.
*/
read(providerId: string): Promise<Credential | undefined>;
/**
* List stored credential metadata without resolving or exposing secrets.
* Implementations must not execute configured API-key commands while listing.
*/
list(): Promise<readonly CredentialInfo[]>;
/**
* Serialized write — the only write path. `fn` sees the current credential
* because correct writes (refresh, login-during-refresh) depend on it;
* return the new credential, or undefined to leave the entry unchanged.
* Mutual exclusion per provider id, cross-process too where the backing
* store supports it (e.g. a file lock). Resolves with the post-write
* credential. Rejections from `fn` propagate.
*/
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined>;
/** Remove a credential (logout). Implementations serialize this against `modify`. */
delete(providerId: string): Promise<void>;
}
/** Environment access for auth resolution. Injectable for tests and browsers. */
export interface AuthContext {
env(name: string): Promise<string | undefined>;
/** Check whether a file exists. Supports a leading `~`. Always false in browsers. */
fileExists(path: string): Promise<boolean>;
}
/** Result of resolving auth for a model. */
export interface AuthResult {
auth: ModelAuth;
/** Provider-scoped environment/config values resolved from credentials and ambient context. */
env?: ProviderEnv;
/** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */
source?: string;
}
export interface AuthCheck {
source?: string;
type: "api_key" | "oauth";
}
export type AuthType = "api_key" | "oauth";
/**
* Prompt shown to the user during login. `signal` lets the flow cancel a
* pending prompt when an out-of-band event resolves the step, e.g. a
* `manual_code` prompt raced against a callback server, aborted when the
* callback wins.
*/
export type AuthPrompt = { signal?: AbortSignal } & (
| { type: "text"; message: string; placeholder?: string }
| { type: "secret"; message: string; placeholder?: string }
| { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] }
| { type: "manual_code"; message: string; placeholder?: string }
);
export interface AuthInfoLink {
url: string;
label?: string;
}
export type AuthEvent =
| { type: "info"; message: string; links?: readonly AuthInfoLink[] }
| { type: "auth_url"; url: string; instructions?: string }
| {
type: "device_code";
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}
| { type: "progress"; message: string };
/**
* Login interaction callbacks serving both api-key and OAuth flows.
*
* `prompt()` returns the entered/selected string (`select` returns the option
* id). Rejects on cancel/abort. `signal` aborts the whole login flow;
* per-prompt cancellation uses `AuthPrompt.signal`.
*/
export interface AuthInteraction {
signal?: AbortSignal;
prompt(prompt: AuthPrompt): Promise<string>;
notify(event: AuthEvent): void;
}
/**
* Api-key auth: stored key/provider env plus ambient sources (env vars, AWS
* profiles, ADC files). Ambient-only providers omit `login`.
*/
export interface ApiKeyAuth {
/** Display name, e.g. "Anthropic API key". */
name: string;
/** Interactive setup (prompt for key/provider env). Absent = ambient-only. */
login?(interaction: AuthInteraction): Promise<ApiKeyCredential>;
/**
* Optional side-effect-free availability check. Use this when `resolve()` may
* execute commands or perform other request-time work. Missing means Models
* checks availability by resolving auth.
*/
check?(input: { ctx: AuthContext; credential?: ApiKeyCredential }): Promise<AuthCheck | undefined>;
/**
* Resolve auth from the stored credential and/or ambient sources, merging
* per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`).
* undefined = not configured. Resolution is provider-scoped; model-specific
* endpoint preparation happens after auth has been resolved.
*/
resolve(input: { ctx: AuthContext; credential?: ApiKeyCredential }): Promise<AuthResult | undefined>;
}
/**
* OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
* refresh pattern: `refresh` produces a credential, `toAuth` derives request
* auth from whatever credential ends up stored.
*/
export interface OAuthAuth {
/** Display name, e.g. "Anthropic (Claude Pro/Max)". */
name: string;
/** Selector label for the subscription login option, e.g. "Sign in with SuperGrok or X Premium". */
loginLabel?: string;
login(interaction: AuthInteraction): Promise<OAuthCredential>;
/**
* Exchange the refresh token. Network call; throws on failure
* (invalid_grant etc.). `Models` runs this under the store lock.
*/
refresh(credential: OAuthCredential, signal?: AbortSignal): Promise<OAuthCredential>;
/**
* Side-effect-free derivation of request auth from a valid credential.
* Covers per-credential baseUrl (GitHub Copilot). Async so lazy wrappers
* can load the implementation on first use.
*/
toAuth(credential: OAuthCredential): Promise<ModelAuth>;
}
/**
* Provider auth. At least one of `apiKey`/`oauth` must be present: even
* ambient-credential providers and keyless local servers provide `apiKey`
* auth whose `resolve()` reports whether the provider is configured.
*/
export interface ProviderAuth {
apiKey?: ApiKeyAuth;
oauth?: OAuthAuth;
}