import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { parseOpenAiChatGptTokenInfo, refreshOpenAiChatGptTokenData, } from '../ts/smartai.auth.openai.js'; import type { IOpenAiChatGptAuthOptions, IOpenAiChatGptTokenData, IOpenAiChatGptTokenInfo, } from '../ts/smartai.interfaces.js'; export type TOpenAiChatGptAuthSource = 'smartai' | 'opencode' | 'codex'; export type TOpenAiChatGptAuthFileFormat = TOpenAiChatGptAuthSource | 'auto'; export interface IOpenAiChatGptAuthSourceConfig { source: TOpenAiChatGptAuthSource; filePath?: string; format?: TOpenAiChatGptAuthFileFormat; writeBack?: boolean; } export interface IOpenAiChatGptAuthSourceInspection { source: TOpenAiChatGptAuthSource; filePath: string; exists: boolean; usable: boolean; expired?: boolean; accountId?: string; email?: string; plan?: string; expiresAt?: string; error?: string; } export interface IInspectOpenAiChatGptAuthSourcesOptions { sources?: Array; homeDir?: string; now?: Date; } export interface IResolveOpenAiChatGptAuthOptions extends IInspectOpenAiChatGptAuthSourcesOptions { refresh?: 'ifNeeded' | false; writeBack?: Partial>; authOptions?: IOpenAiChatGptAuthOptions; } export interface IResolvedOpenAiChatGptAuth { source: TOpenAiChatGptAuthSource; filePath: string; tokenData: IOpenAiChatGptTokenData; refreshed: boolean; } interface INormalizedOpenAiChatGptAuthSourceConfig { source: TOpenAiChatGptAuthSource; filePath: string; format: TOpenAiChatGptAuthFileFormat; writeBack?: boolean; } const defaultSources: TOpenAiChatGptAuthSource[] = ['smartai', 'opencode', 'codex']; const refreshWindowMs = 5 * 60 * 1000; export const getDefaultOpenAiChatGptAuthPath = ( source: TOpenAiChatGptAuthSource, homeDir = os.homedir(), ): string => { switch (source) { case 'smartai': return path.join(homeDir, '.git.zone', 'ide', 'openai-chatgpt-auth.json'); case 'opencode': return path.join(homeDir, '.local', 'share', 'opencode', 'auth.json'); case 'codex': return path.join(homeDir, '.codex', 'auth.json'); default: throw new Error(`Unsupported OpenAI ChatGPT auth source: ${source satisfies never}`); } }; export const normalizeOpenAiChatGptAuth = ( input: unknown, format: TOpenAiChatGptAuthFileFormat = 'auto', ): IOpenAiChatGptTokenData | undefined => { if (format === 'auto') { return normalizeOpenAiChatGptAuth(input, 'smartai') ?? normalizeOpenAiChatGptAuth(input, 'opencode') ?? normalizeOpenAiChatGptAuth(input, 'codex'); } if (!isRecord(input)) return undefined; if (format === 'opencode') { return normalizeOpenCodeAuth(input); } return normalizeTokenObject(isRecord(input.tokens) ? input.tokens : input); }; export const readOpenAiChatGptAuthFile = async ( filePath: string, format: TOpenAiChatGptAuthFileFormat = 'auto', ): Promise => { const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown; return normalizeOpenAiChatGptAuth(parsed, format); }; export const inspectOpenAiChatGptAuthSources = async ( options: IInspectOpenAiChatGptAuthSourcesOptions = {}, ): Promise => { const now = options.now ?? new Date(); const sourceConfigs = normalizeSourceConfigs(options.sources, options.homeDir); return Promise.all(sourceConfigs.map(async (sourceConfig) => { try { const tokenData = await readOpenAiChatGptAuthFile(sourceConfig.filePath!, sourceConfig.format ?? sourceConfig.source); if (!tokenData) { return { source: sourceConfig.source, filePath: sourceConfig.filePath!, exists: true, usable: false, error: 'No OpenAI ChatGPT auth token found in file.', }; } return toInspection(sourceConfig.source, sourceConfig.filePath!, tokenData, now); } catch (error) { const nodeError = error as NodeJS.ErrnoException; return { source: sourceConfig.source, filePath: sourceConfig.filePath!, exists: nodeError.code !== 'ENOENT', usable: false, error: nodeError.code === 'ENOENT' ? undefined : nodeError.message, }; } })); }; export const resolveOpenAiChatGptAuth = async ( options: IResolveOpenAiChatGptAuthOptions = {}, ): Promise => { const now = options.now ?? new Date(); const sourceConfigs = normalizeSourceConfigs(options.sources, options.homeDir); const refresh = options.refresh ?? 'ifNeeded'; for (const sourceConfig of sourceConfigs) { let tokenData: IOpenAiChatGptTokenData | undefined; try { tokenData = await readOpenAiChatGptAuthFile(sourceConfig.filePath!, sourceConfig.format ?? sourceConfig.source); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') continue; continue; } if (!tokenData) continue; const shouldRefresh = refresh === 'ifNeeded' && shouldRefreshToken(tokenData, now); const writeBack = sourceConfig.writeBack ?? options.writeBack?.[sourceConfig.source] ?? sourceConfig.source === 'smartai'; if (!shouldRefresh) { return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData, refreshed: false }; } if (!writeBack) { if (!isExpired(tokenData, now)) { return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData, refreshed: false }; } continue; } try { const refreshed = await refreshOpenAiChatGptTokenData(tokenData, options.authOptions ?? {}); await writeOpenAiChatGptAuthFile(sourceConfig.filePath!, refreshed, sourceConfig.format ?? sourceConfig.source); return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData: refreshed, refreshed: true }; } catch { continue; } } return undefined; }; export const writeOpenAiChatGptAuthFile = async ( filePath: string, tokenData: IOpenAiChatGptTokenData, format: TOpenAiChatGptAuthFileFormat = 'smartai', ): Promise => { const current = await readJsonFileIfExists(filePath); const payload = format === 'opencode' ? toOpenCodeAuthFile(current, tokenData) : format === 'codex' ? toCodexAuthFile(current, tokenData) : toSmartAiAuthFile(tokenData); await writeJsonAtomic(filePath, payload); }; const normalizeSourceConfigs = ( sources: Array | undefined, homeDir = os.homedir(), ): INormalizedOpenAiChatGptAuthSourceConfig[] => { return (sources ?? defaultSources).map((sourceInput) => { const sourceConfig = typeof sourceInput === 'string' ? { source: sourceInput } : sourceInput; return { source: sourceConfig.source, filePath: sourceConfig.filePath ?? getDefaultOpenAiChatGptAuthPath(sourceConfig.source, homeDir), format: sourceConfig.format ?? sourceConfig.source, writeBack: sourceConfig.writeBack, }; }); }; const toInspection = ( source: TOpenAiChatGptAuthSource, filePath: string, tokenData: IOpenAiChatGptTokenData, now: Date, ): IOpenAiChatGptAuthSourceInspection => { const expired = isExpired(tokenData, now); return { source, filePath, exists: true, usable: !expired, expired, accountId: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId, email: tokenData.tokenInfo.email, plan: tokenData.tokenInfo.chatgptPlanType, expiresAt: tokenData.tokenInfo.expiresAt, }; }; const normalizeTokenObject = (input: Record): IOpenAiChatGptTokenData | undefined => { const accessToken = stringValue(input.accessToken) ?? stringValue(input.access_token); const refreshToken = stringValue(input.refreshToken) ?? stringValue(input.refresh_token); const idToken = stringValue(input.idToken) ?? stringValue(input.id_token) ?? stringValue((input.tokenInfo as Record | undefined)?.rawJwt); const accountId = stringValue(input.accountId) ?? stringValue(input.account_id); return createTokenDataFromValues({ accessToken, refreshToken, idToken, accountId }); }; const normalizeOpenCodeAuth = (input: Record): IOpenAiChatGptTokenData | undefined => { if (!isRecord(input.openai)) return undefined; const accessToken = stringValue(input.openai.access); const refreshToken = stringValue(input.openai.refresh); const accountId = stringValue(input.openai.accountId); const expiresAt = typeof input.openai.expires === 'number' ? new Date(input.openai.expires).toISOString() : undefined; return createTokenDataFromValues({ accessToken, refreshToken, accountId, fallbackExpiresAt: expiresAt }); }; const createTokenDataFromValues = (input: { accessToken?: string; refreshToken?: string; idToken?: string; accountId?: string; fallbackExpiresAt?: string; }): IOpenAiChatGptTokenData | undefined => { if (!input.accessToken || !input.refreshToken) return undefined; const parseToken = input.idToken ?? input.accessToken; const tokenInfo = parseTokenInfo(parseToken, input.accountId, input.fallbackExpiresAt); return { accessToken: input.accessToken, refreshToken: input.refreshToken, idToken: input.idToken, accountId: input.accountId ?? tokenInfo.chatgptAccountId, tokenInfo, }; }; const parseTokenInfo = (token: string, accountId?: string, fallbackExpiresAt?: string): IOpenAiChatGptTokenInfo => { try { const tokenInfo = parseOpenAiChatGptTokenInfo(token); return { ...tokenInfo, chatgptAccountId: tokenInfo.chatgptAccountId ?? accountId, expiresAt: tokenInfo.expiresAt ?? fallbackExpiresAt, }; } catch { return { chatgptAccountId: accountId, chatgptAccountIsFedramp: false, expiresAt: fallbackExpiresAt, rawJwt: token, }; } }; const toSmartAiAuthFile = (tokenData: IOpenAiChatGptTokenData): Record => ({ accessToken: tokenData.accessToken, refreshToken: tokenData.refreshToken, idToken: tokenData.idToken, accountId: tokenData.accountId, tokenInfo: tokenData.tokenInfo, }); const toCodexAuthFile = (current: Record, tokenData: IOpenAiChatGptTokenData): Record => ({ ...current, OPENAI_API_KEY: current.OPENAI_API_KEY ?? null, tokens: { ...(isRecord(current.tokens) ? current.tokens : {}), id_token: tokenData.idToken, access_token: tokenData.accessToken, refresh_token: tokenData.refreshToken, account_id: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId, }, last_refresh: new Date().toISOString(), }); const toOpenCodeAuthFile = (current: Record, tokenData: IOpenAiChatGptTokenData): Record => ({ ...current, openai: { ...(isRecord(current.openai) ? current.openai : {}), type: 'oauth', refresh: tokenData.refreshToken, access: tokenData.accessToken, expires: tokenData.tokenInfo.expiresAt ? Date.parse(tokenData.tokenInfo.expiresAt) : undefined, accountId: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId, }, }); const shouldRefreshToken = (tokenData: IOpenAiChatGptTokenData, now: Date): boolean => { if (!tokenData.tokenInfo.expiresAt) return false; return Date.parse(tokenData.tokenInfo.expiresAt) - now.getTime() < refreshWindowMs; }; const isExpired = (tokenData: IOpenAiChatGptTokenData, now: Date): boolean => { if (!tokenData.tokenInfo.expiresAt) return false; return Date.parse(tokenData.tokenInfo.expiresAt) <= now.getTime(); }; const readJsonFileIfExists = async (filePath: string): Promise> => { try { const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown; return isRecord(parsed) ? parsed : {}; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') return {}; throw error; } }; const writeJsonAtomic = async (filePath: string, payload: unknown): Promise => { const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); await fs.writeFile(tempPath, `${JSON.stringify(payload, undefined, 2)}\n`, { mode: 0o600 }); await fs.rename(tempPath, filePath); }; const isRecord = (value: unknown): value is Record => { return !!value && typeof value === 'object' && !Array.isArray(value); }; const stringValue = (value: unknown): string | undefined => { return typeof value === 'string' && value.length > 0 ? value : undefined; };