Squashed 'src/pi/' content from commit d981de122

git-subtree-dir: src/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-13 07:04:45 -04:00
commit 25b62d1feb
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
/**
* 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 { OAuthAuth, OAuthCredential, ProviderAuthInteraction } 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>, signal: AbortSignal): Promise<string> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.any([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,
signal: AbortSignal,
): 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,
},
signal,
);
} 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: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const server = await startCallbackServer(verifier);
const manualAbort = new AbortController();
const onAbort = () => server.cancelWait();
interaction.signal.addEventListener("abort", onAbort, { once: true });
if (interaction.signal.aborted) onAbort();
let code: string | undefined;
let state: string | undefined;
let manualInput: string | undefined;
let manualError: Error | undefined;
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, interaction.signal);
} finally {
interaction.signal.removeEventListener("abort", onAbort);
manualAbort.abort();
server.server.close();
}
}
/**
* Refresh Anthropic OAuth token
*/
async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredential> {
let responseBody: string;
try {
responseBody = await postJson(
TOKEN_URL,
{
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
},
signal,
);
} 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)",
isSubscription: true,
login: loginAnthropic,
refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal),
async toAuth(credential) {
return { apiKey: credential.access };
},
};

View File

@@ -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;
};
export function abortableSleep(ms: number, signal: AbortSignal, 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);
}

View File

@@ -0,0 +1,507 @@
/**
* GitHub Copilot OAuth flow
*/
import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts";
import { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } 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 parseGitHubCopilotModelCatalog(raw: unknown, allowPolicyFallback: boolean) {
const data = asRecord(raw)?.data;
if (!Array.isArray(data)) {
throw new Error("Invalid Copilot models response");
}
const accountModels = data.flatMap((rawItem) => {
const item = asRecord(rawItem);
const id = item?.id;
if (!item || typeof id !== "string") return [];
const capabilities = asRecord(item.capabilities);
const supports = asRecord(capabilities?.supports);
if (supports?.tool_calls === false) return [];
return [
{
id,
pickerEnabled: item.model_picker_enabled === true,
policyState: asRecord(item.policy)?.state,
},
];
});
const pickerModelIds = accountModels
.filter((model) => model.pickerEnabled && model.policyState !== "disabled")
.map((model) => model.id);
const usePolicyFallback = allowPolicyFallback && pickerModelIds.length === 0;
const availableModelIds =
pickerModelIds.length > 0 || !allowPolicyFallback
? pickerModelIds
: accountModels.filter((model) => model.policyState === "enabled").map((model) => model.id);
const policyModelIds = accountModels
.filter(
(model) =>
model.policyState === "unconfigured" &&
Object.hasOwn(GITHUB_COPILOT_MODELS, model.id) &&
(model.pickerEnabled || usePolicyFallback),
)
.map((model) => model.id);
return { availableModelIds, policyModelIds };
}
async function fetchWithRateLimitRetry(
url: string,
init: RequestInit,
signal: AbortSignal,
retryPolicy: { maxRetries: number; maxElapsedMs: number },
): Promise<Response> {
const retryBudgetSignal =
retryPolicy.maxRetries > 0 && retryPolicy.maxElapsedMs > 0
? AbortSignal.timeout(retryPolicy.maxElapsedMs)
: undefined;
const requestSignal = retryBudgetSignal ? AbortSignal.any([signal, retryBudgetSignal]) : signal;
const retryDeadline = retryBudgetSignal ? Date.now() + retryPolicy.maxElapsedMs : undefined;
for (let retry = 0; ; retry++) {
const response = await fetch(url, {
...init,
signal: AbortSignal.any([requestSignal, AbortSignal.timeout(5000)]),
});
if (response.status !== 429 || retry === retryPolicy.maxRetries) return response;
const retryAfter = response.headers.get("retry-after");
let delayMs = 500 * 2 ** retry;
if (retryAfter) {
const seconds = Number.parseFloat(retryAfter);
delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
if (!Number.isFinite(delayMs)) return response;
}
delayMs = Math.max(0, delayMs);
if (retryDeadline !== undefined && delayMs >= retryDeadline - Date.now()) return response;
await response.body?.cancel();
await sleep(delayMs, requestSignal);
}
}
async function fetchGitHubCopilotModels(
copilotToken: string,
enterpriseDomain: string | undefined,
signal: AbortSignal,
retryPolicy: { maxRetries: number; maxElapsedMs: number },
) {
const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain);
// Some Individual accounts return false for every picker flag despite explicit enabled policies.
// Limit the fallback to that endpoint so other account types keep strict picker semantics.
const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com";
const response = await fetchWithRateLimitRetry(
`${baseUrl}/models`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${copilotToken}`,
...COPILOT_HEADERS,
"X-GitHub-Api-Version": COPILOT_API_VERSION,
},
},
signal,
retryPolicy,
);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
}
return parseGitHubCopilotModelCatalog(await response.json(), allowPolicyFallback);
}
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, signal: AbortSignal): 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",
}),
signal,
});
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",
}),
signal,
});
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 | undefined,
signal: AbortSignal,
): 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,
},
signal,
});
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 | undefined,
signal: AbortSignal,
): Promise<OAuthCredential> {
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain, signal);
const { availableModelIds } = await fetchGitHubCopilotModels(credentials.access, enterpriseDomain, signal, {
maxRetries: 0,
maxElapsedMs: 0,
});
return {
...credentials,
availableModelIds,
};
}
/**
* 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 | undefined,
signal: AbortSignal,
): Promise<boolean> {
const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
const url = `${baseUrl}/models/${modelId}/policy`;
let response: Response;
try {
response = await fetchWithRateLimitRetry(
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" }),
},
signal,
{ maxRetries: 2, maxElapsedMs: 5000 },
);
} catch (error) {
if (signal.aborted) throw error;
return false;
}
if (response.status === 429) {
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
}
return response.ok;
}
/**
* Enable the requested GitHub Copilot models and return the successful IDs.
* Policy updates are best effort; exhausted rate limiting stops the batch.
*/
async function enableGitHubCopilotModels(
token: string,
modelIds: readonly string[],
enterpriseDomain: string | undefined,
signal: AbortSignal,
): Promise<string[]> {
const enabledModelIds: string[] = [];
for (const modelId of modelIds) {
try {
if (await enableGitHubCopilotModel(token, modelId, enterpriseDomain, signal)) {
enabledModelIds.push(modelId);
}
} catch (error) {
if (signal.aborted) throw error;
break;
}
}
return enabledModelIds;
}
async function loginGitHubCopilot(interaction: ProviderAuthInteraction): 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.signal);
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.signal,
);
const models = await fetchGitHubCopilotModels(
credentials.access,
enterpriseDomain ?? undefined,
interaction.signal,
{
maxRetries: 2,
maxElapsedMs: 5000,
},
);
let enabledModelIds: string[] = [];
if (models.policyModelIds.length > 0) {
interaction.notify({ type: "progress", message: "Enabling models..." });
enabledModelIds = await enableGitHubCopilotModels(
credentials.access,
models.policyModelIds,
enterpriseDomain ?? undefined,
interaction.signal,
);
}
return {
...credentials,
availableModelIds: [...new Set([...models.availableModelIds, ...enabledModelIds])],
};
}
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",
isSubscription: true,
login: loginGitHubCopilot,
refresh: (credential, signal) =>
refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential), signal),
/** Derive the credential-specific proxy endpoint for each request. */
async toAuth(credential) {
return {
apiKey: credential.access,
baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)),
};
},
};

View File

@@ -0,0 +1,296 @@
/**
* 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 { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } 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]);
}
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 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), signal);
}
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: ProviderAuthInteraction): 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)",
isSubscription: true,
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}` } };
},
};

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);
};

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,
});
}

View File

@@ -0,0 +1,544 @@
/**
* 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 { OAuthAuth, OAuthCredential, ProviderAuthInteraction } 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,
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, signal: AbortSignal): 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,
}),
signal,
});
} 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: ProviderAuthInteraction): 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: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, state, url } = await createAuthorizationFlow();
const server = await startLocalOAuthServer(state);
const manualAbort = new AbortController();
const onAbort = () => server.cancelWait();
interaction.signal.addEventListener("abort", onAbort, { once: true });
if (interaction.signal.aborted) onAbort();
let code: string | undefined;
let manualCode: string | undefined;
let manualError: Error | undefined;
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 {
interaction.signal.removeEventListener("abort", onAbort);
manualAbort.abort();
server.close();
}
}
/**
* Refresh OpenAI Codex OAuth token
*/
async function refreshOpenAICodexToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredential> {
return credentialsFromToken(await refreshAccessToken(refreshToken, signal));
}
export const openaiCodexOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT Plus/Pro)",
isSubscription: true,
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, signal) => refreshOpenAICodexToken(credential.refresh, signal),
async toAuth(credential) {
return { apiKey: credential.access };
},
};

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 { OAuthAuth, OAuthCredential, ProviderAuthInteraction } 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: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
const manualAbort = new AbortController();
let manualInput: string | undefined;
let manualError: Error | undefined;
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, _signal) {
return credential;
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};

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 };
}

View File

@@ -0,0 +1,403 @@
/**
* 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 { OAuthAuth, OAuthCredential, ProviderAuthInteraction } 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, signal: AbortSignal): Promise<RadiusOAuthDiscovery> {
const response = await fetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" },
signal,
});
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): 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: ProviderAuthInteraction,
): 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): 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: ProviderAuthInteraction): 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, interaction.signal);
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 };
},
};
}

View File

@@ -0,0 +1,239 @@
/**
* xAI OAuth device-code flow.
*/
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } 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: ProviderAuthInteraction): 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)",
isSubscription: true,
loginLabel: "Sign in with SuperGrok or X Premium",
login: loginXai,
refresh: (credential, signal) => refreshXaiToken(credential.refresh, signal),
async toAuth(credential) {
return { apiKey: credential.access };
},
};