cc-haha/src/server/services/hahaOpenAIOAuthService.ts
程序员阿江(Relakkes) 0927c80cdc Unify desktop OpenAI OAuth token storage
Desktop OpenAI authorization lived in the cc-haha token file while the runtime only read secure storage, so desktop-managed logins could not refresh or authenticate CLI/runtime paths consistently. This teaches runtime storage to honor the explicit desktop token-file env, preserves refresh metadata when OpenAI omits fields, and keeps the desktop service writing the same compatible token shape.

Constraint: Desktop sessions must survive macOS Keychain ACL failures by using the desktop-managed token file when it is explicitly configured
Rejected: Keep refresh writes in secure storage only | runtime and desktop would remain split and refreshed tokens would drift
Confidence: high
Scope-risk: moderate
Tested: bun test src/services/openaiAuth/storage.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts
Tested: git diff --check
2026-05-18 22:56:26 +08:00

228 lines
6.6 KiB
TypeScript

/**
* 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<OpenAIOAuthTokenResponse>
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<string, OpenAIOAuthSession>()
private refreshFn: OpenAIRefreshFn = refreshOpenAITokens
setRefreshFn(fn: OpenAIRefreshFn): void {
this.refreshFn = fn
}
getOAuthFilePath(): string {
return getHahaOpenAIOAuthFilePath()
}
async loadTokens(): Promise<StoredOpenAIOAuthTokens | null> {
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<void> {
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<void> {
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<StoredOpenAIOAuthTokens> {
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<StoredOpenAIOAuthTokens | null> {
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<string | null> {
const tokens = await this.ensureFreshTokens()
return tokens?.accessToken ?? null
}
}
export const hahaOpenAIOAuthService = new HahaOpenAIOAuthService()