/** * HahaOpenAIOAuthService — 桌面端自管 OpenAI OAuth token * * 为什么存在: macOS Keychain ACL 在 .app 被打上 quarantine 属性后 * 对无 UI sidecar 静默拒绝,导致 CLI 读不到 OAuth token → 403。 * 这个 service 把 token 存到 haha 自己的目录,并通过 env 注入给 CLI。 * * 复用 src/services/openaiAuth/client.ts 里的 PKCE + token exchange 逻辑, * 不复制粘贴 —— 保证跟 CLI 走同一套协议实现。 */ import * as fs from 'fs/promises' import * as os from 'os' import * as path from 'path' import { generateCodeVerifier, generateCodeChallenge, generateState, } from '../../services/oauth/crypto.js' import { buildOpenAIAuthorizeUrl, exchangeOpenAICodeForTokens, refreshOpenAITokens, isOpenAITokenExpired, normalizeOpenAITokens, withRefreshedAccessToken, OPENAI_CODEX_OAUTH_PORT, OPENAI_CODEX_REDIRECT_PATH, } from '../../services/openaiAuth/client.js' import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js' export type StoredOpenAIOAuthTokens = { accessToken: string refreshToken: string | null expiresAt: number | null idToken?: string | null email: string | null accountId: string | null clientId?: string | null } export type OpenAIOAuthSession = { state: string codeVerifier: string authorizeUrl: string serverPort: number createdAt: number } type OpenAIRefreshFn = ( refreshToken: string, ) => Promise const SESSION_TTL_MS = 5 * 60 * 1000 export function getHahaOpenAIOAuthFilePath(): string { const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') return path.join(configDir, 'cc-haha', 'openai-oauth.json') } export class HahaOpenAIOAuthService { private sessions = new Map() private refreshFn: OpenAIRefreshFn = refreshOpenAITokens setRefreshFn(fn: OpenAIRefreshFn): void { this.refreshFn = fn } getOAuthFilePath(): string { return getHahaOpenAIOAuthFilePath() } async loadTokens(): Promise { try { const raw = await fs.readFile(this.getOAuthFilePath(), 'utf-8') return JSON.parse(raw) as StoredOpenAIOAuthTokens } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null throw err } } async saveTokens(tokens: StoredOpenAIOAuthTokens): Promise { const filePath = this.getOAuthFilePath() await fs.mkdir(path.dirname(filePath), { recursive: true }) const tmp = `${filePath}.tmp.${process.pid}` await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 }) await fs.rename(tmp, filePath) } async deleteTokens(): Promise { try { await fs.unlink(this.getOAuthFilePath()) } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err } } startSession({ serverPort }: { serverPort: number }): OpenAIOAuthSession { this.pruneExpiredSessions() const codeVerifier = generateCodeVerifier() const state = generateState() const redirectUri = `http://localhost:${serverPort}${OPENAI_CODEX_REDIRECT_PATH}` const authorizeUrl = buildOpenAIAuthorizeUrl({ redirectUri, codeVerifier, state, }) const session: OpenAIOAuthSession = { state, codeVerifier, authorizeUrl, serverPort, createdAt: Date.now(), } this.sessions.set(state, session) return session } getSession(state: string): OpenAIOAuthSession | null { const s = this.sessions.get(state) if (!s) return null if (Date.now() - s.createdAt > SESSION_TTL_MS) { this.sessions.delete(state) return null } return s } consumeSession(state: string): OpenAIOAuthSession | null { const s = this.getSession(state) if (s) this.sessions.delete(state) return s } private pruneExpiredSessions(): void { const now = Date.now() for (const [state, s] of this.sessions.entries()) { if (now - s.createdAt > SESSION_TTL_MS) this.sessions.delete(state) } } async completeSession( authorizationCode: string, state: string, ): Promise { const session = this.consumeSession(state) if (!session) { throw new Error('OpenAI OAuth session not found or expired') } const redirectUri = `http://localhost:${session.serverPort}${OPENAI_CODEX_REDIRECT_PATH}` const response = await exchangeOpenAICodeForTokens({ code: authorizationCode, redirectUri, codeVerifier: session.codeVerifier, }) const normalized = normalizeOpenAITokens(response) const tokens: StoredOpenAIOAuthTokens = { accessToken: normalized.accessToken, refreshToken: normalized.refreshToken, expiresAt: normalized.expiresAt, idToken: normalized.idToken ?? null, email: normalized.email ?? null, accountId: normalized.accountId ?? null, clientId: normalized.clientId ?? null, } await this.saveTokens(tokens) return tokens } async ensureFreshTokens(): Promise { const tokens = await this.loadTokens() if (!tokens) return null if (tokens.expiresAt === null) return tokens if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens if (!tokens.refreshToken) return null try { const refreshed = await this.refreshFn(tokens.refreshToken) const normalized = withRefreshedAccessToken( { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, ...(tokens.idToken ? { idToken: tokens.idToken } : {}), ...(tokens.email ? { email: tokens.email } : {}), ...(tokens.accountId ? { accountId: tokens.accountId } : {}), ...(tokens.clientId ? { clientId: tokens.clientId } : {}), }, refreshed, ) const updated: StoredOpenAIOAuthTokens = { accessToken: normalized.accessToken, refreshToken: normalized.refreshToken, expiresAt: normalized.expiresAt, idToken: normalized.idToken ?? null, email: normalized.email ?? null, accountId: normalized.accountId ?? null, clientId: normalized.clientId ?? null, } await this.saveTokens(updated) return updated } catch (err) { console.error( '[HahaOpenAIOAuthService] token refresh failed:', err instanceof Error ? err.message : err, ) return null } } async ensureFreshAccessToken(): Promise { const tokens = await this.ensureFreshTokens() return tokens?.accessToken ?? null } } export const hahaOpenAIOAuthService = new HahaOpenAIOAuthService()