Files
flynn/src/auth/openai.ts
T
2026-02-15 10:26:19 -08:00

391 lines
11 KiB
TypeScript

import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
const ISSUER = 'https://auth.openai.com';
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const DEVICE_URL = `${ISSUER}/codex/device`;
const DEVICE_CODE_URL = `${ISSUER}/api/accounts/deviceauth/usercode`;
const DEVICE_TOKEN_URL = `${ISSUER}/api/accounts/deviceauth/token`;
const TOKEN_URL = `${ISSUER}/oauth/token`;
const POLLING_SAFETY_MARGIN_MS = 3000;
const REFRESH_SAFETY_MARGIN_MS = 30_000;
const AUTH_DIR = resolve(homedir(), '.config/flynn');
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
export interface OpenAIOAuthInfo {
access_token: string;
refresh_token: string;
/** Epoch millis. */
expires_at: number;
/** Optional account/org id used for subscription routing. */
account_id?: string;
created_at: string;
}
export interface OpenAIApiKeyInfo {
api_key: string;
created_at: string;
}
interface OpenAIStoreEntry {
oauth?: OpenAIOAuthInfo;
api_key?: OpenAIApiKeyInfo;
}
interface AuthStore {
// Leave github entry untyped here so this module does not depend on github.ts.
github?: unknown;
/** OpenAI credentials. Backward compatible with legacy OAuth-only entries. */
openai?: OpenAIStoreEntry | OpenAIOAuthInfo;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isOpenAIOAuthInfo(value: unknown): value is OpenAIOAuthInfo {
if (!isRecord(value)) {
return false;
}
return typeof value.access_token === 'string'
&& typeof value.refresh_token === 'string'
&& typeof value.expires_at === 'number'
&& typeof value.created_at === 'string';
}
function isOpenAIApiKeyInfo(value: unknown): value is OpenAIApiKeyInfo {
if (!isRecord(value)) {
return false;
}
return typeof value.api_key === 'string'
&& typeof value.created_at === 'string';
}
function readOpenAIEntry(store: AuthStore): OpenAIStoreEntry | null {
const raw = store.openai as unknown;
if (!raw) {
return null;
}
// Legacy format: auth.json.openai stored the OAuth info directly.
if (isOpenAIOAuthInfo(raw)) {
return { oauth: raw };
}
if (!isRecord(raw)) {
return null;
}
const oauth = isOpenAIOAuthInfo(raw.oauth) ? raw.oauth : undefined;
const apiKey = isOpenAIApiKeyInfo(raw.api_key) ? raw.api_key : undefined;
return { oauth, api_key: apiKey };
}
function writeOpenAIEntry(store: AuthStore, entry: OpenAIStoreEntry | null): void {
if (!entry || (!entry.oauth && !entry.api_key)) {
delete store.openai;
return;
}
store.openai = entry;
}
interface DeviceAuthResponse {
device_auth_id: string;
user_code: string;
interval: string;
}
interface DeviceTokenResponse {
authorization_code: string;
code_verifier: string;
}
interface TokenResponse {
id_token?: string;
access_token: string;
refresh_token: string;
expires_in?: number;
}
export interface IdTokenClaims {
chatgpt_account_id?: string;
organizations?: Array<{ id: string }>;
'https://api.openai.com/auth'?: {
chatgpt_account_id?: string;
};
}
function safeJsonParse<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function readAuthStore(): AuthStore {
try {
const raw = readFileSync(AUTH_FILE, 'utf-8');
const parsed = safeJsonParse<AuthStore>(raw);
return parsed ?? {};
} catch {
return {};
}
}
function writeAuthStore(store: AuthStore): void {
mkdirSync(AUTH_DIR, { recursive: true });
writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8');
chmodSync(AUTH_FILE, 0o600);
}
export function loadStoredOpenAIAuth(): OpenAIOAuthInfo | null {
const store = readAuthStore();
const entry = readOpenAIEntry(store);
return entry?.oauth ?? null;
}
export function storeOpenAIAuth(info: OpenAIOAuthInfo): void {
const store = readAuthStore();
const entry = readOpenAIEntry(store) ?? {};
entry.oauth = info;
writeOpenAIEntry(store, entry);
writeAuthStore(store);
}
export function clearOpenAIAuth(): void {
const store = readAuthStore();
const entry = readOpenAIEntry(store);
if (entry) {
delete entry.oauth;
writeOpenAIEntry(store, entry);
} else {
delete store.openai;
}
writeAuthStore(store);
}
export function loadStoredOpenAIApiKey(): string | null {
const store = readAuthStore();
const entry = readOpenAIEntry(store);
return entry?.api_key?.api_key ?? null;
}
export function storeOpenAIApiKey(key: string): void {
const trimmed = key.trim();
if (!trimmed) {
throw new Error('OpenAI API key is empty');
}
const store = readAuthStore();
const entry = readOpenAIEntry(store) ?? {};
entry.api_key = { api_key: trimmed, created_at: new Date().toISOString() };
writeOpenAIEntry(store, entry);
writeAuthStore(store);
}
export function clearOpenAIApiKey(): void {
const store = readAuthStore();
const entry = readOpenAIEntry(store);
if (!entry) {
return;
}
delete entry.api_key;
writeOpenAIEntry(store, entry);
writeAuthStore(store);
}
/**
* Get an OpenAI API key from any available source.
* Priority: OPENAI_API_KEY → stored auth.json.
*/
export function getOpenAIApiKey(): string | null {
return process.env.OPENAI_API_KEY
?? loadStoredOpenAIApiKey()
?? null;
}
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
const parts = token.split('.');
if (parts.length !== 3) {return undefined;}
try {
return JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as IdTokenClaims;
} catch {
return undefined;
}
}
function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
return claims.chatgpt_account_id
?? claims['https://api.openai.com/auth']?.chatgpt_account_id
?? claims.organizations?.[0]?.id;
}
export function extractAccountId(tokens: TokenResponse): string | undefined {
const idToken = tokens.id_token;
if (idToken) {
const claims = parseJwtClaims(idToken);
const id = claims && extractAccountIdFromClaims(claims);
if (id) {return id;}
}
const accessToken = tokens.access_token;
if (accessToken) {
const claims = parseJwtClaims(accessToken);
return claims ? extractAccountIdFromClaims(claims) : undefined;
}
return undefined;
}
async function requestDeviceAuth(): Promise<DeviceAuthResponse> {
const response = await fetch(DEVICE_CODE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'flynn',
},
body: JSON.stringify({ client_id: CLIENT_ID }),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI device auth start failed (${response.status}): ${body}`);
}
return response.json() as Promise<DeviceAuthResponse>;
}
async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise<DeviceTokenResponse> {
while (true) {
await new Promise(r => setTimeout(r, intervalMs + POLLING_SAFETY_MARGIN_MS));
const response = await fetch(DEVICE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'flynn',
},
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
});
if (response.ok) {
return response.json() as Promise<DeviceTokenResponse>;
}
// OpenCode treats 403/404 as "pending".
if (response.status === 403 || response.status === 404) {
continue;
}
const body = await response.text();
throw new Error(`OpenAI device auth token failed (${response.status}): ${body}`);
}
}
async function exchangeAuthorizationCode(authCode: string, codeVerifier: string): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'flynn',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: `${ISSUER}/deviceauth/callback`,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}).toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI token exchange failed (${response.status}): ${body}`);
}
return response.json() as Promise<TokenResponse>;
}
export async function refreshOpenAIAuth(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'flynn',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI token refresh failed (${response.status}): ${body}`);
}
return response.json() as Promise<TokenResponse>;
}
/**
* Ensure we have a valid (non-expired) OpenAI OAuth access token.
* Refreshes and persists the token if needed.
*/
export async function ensureValidOpenAIAuth(): Promise<OpenAIOAuthInfo> {
const current = loadStoredOpenAIAuth();
if (!current) {
throw new Error('OpenAI OAuth is not configured. Run `flynn openai-auth` to authenticate.');
}
if (current.expires_at > Date.now() + REFRESH_SAFETY_MARGIN_MS) {
return current;
}
const refreshed = await refreshOpenAIAuth(current.refresh_token);
const expiresAt = Date.now() + (refreshed.expires_in ?? 3600) * 1000;
const accountId = extractAccountId(refreshed) ?? current.account_id;
const updated: OpenAIOAuthInfo = {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
expires_at: expiresAt,
account_id: accountId,
created_at: current.created_at,
};
storeOpenAIAuth(updated);
return updated;
}
/**
* Run the OpenAI Codex device flow interactively.
* @param onPrompt Callback to display the user code and verification URL to the user.
*/
export async function loginOpenAI(
onPrompt: (userCode: string, verificationUri: string) => void,
): Promise<OpenAIOAuthInfo> {
const device = await requestDeviceAuth();
const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000;
onPrompt(device.user_code, DEVICE_URL);
const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs);
const tokens = await exchangeAuthorizationCode(deviceToken.authorization_code, deviceToken.code_verifier);
const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000;
const accountId = extractAccountId(tokens);
const info: OpenAIOAuthInfo = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: expiresAt,
...(accountId ? { account_id: accountId } : {}),
created_at: new Date().toISOString(),
};
storeOpenAIAuth(info);
return info;
}