391 lines
11 KiB
TypeScript
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;
|
|
}
|