mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
228 lines
6.6 KiB
TypeScript
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()
|