From 0927c80cdc256ff8db5ed0a8d7e39e160a9debca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 22:56:26 +0800 Subject: [PATCH] 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 --- .../haha-openai-oauth-service.test.ts | 30 ++++- src/server/services/hahaOpenAIOAuthService.ts | 43 ++++-- src/services/openaiAuth/client.ts | 21 ++- src/services/openaiAuth/storage.test.ts | 122 ++++++++++++++++++ src/services/openaiAuth/storage.ts | 93 +++++++++++++ src/services/openaiAuth/types.ts | 5 +- 6 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 src/services/openaiAuth/storage.test.ts diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index 505d9ad9..5e129072 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -8,6 +8,7 @@ import * as path from 'path' import * as os from 'os' import { HahaOpenAIOAuthService, + getHahaOpenAIOAuthFilePath, type StoredOpenAIOAuthTokens, } from '../services/hahaOpenAIOAuthService.js' @@ -46,12 +47,14 @@ describe('HahaOpenAIOAuthService — file storage', () => { accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.mock-access', refreshToken: 'eyJhbGciOiJSUzI1NiJ9.mock-refresh', expiresAt: Date.now() + 3600_000, + idToken: 'mock-id-token', email: 'test@example.com', accountId: 'acct_123', + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', } await service.saveTokens(tokens) - const oauthPath = path.join(tmpDir, 'cc-haha', 'openai-oauth.json') + const oauthPath = getHahaOpenAIOAuthFilePath() const stat = await fs.stat(oauthPath) if (process.platform !== 'win32') { expect(stat.mode & 0o777).toBe(0o600) @@ -167,6 +170,31 @@ describe('HahaOpenAIOAuthService — ensureFreshAccessToken', () => { expect(loaded?.accessToken).toBe('new-fresh-token') }) + test('preserves existing refresh token and id token when refresh omits them', async () => { + await service.saveTokens({ + accessToken: 'expired', + refreshToken: 'refresh-to-preserve', + expiresAt: Date.now() + 60_000, + idToken: 'id-token-to-preserve', + email: 'test@example.com', + accountId: 'acct_123', + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', + }) + + service.setRefreshFn(async () => ({ + access_token: 'new-access-token', + expires_in: 3600, + })) + + const fresh = await service.ensureFreshAccessToken() + expect(fresh).toBe('new-access-token') + + const loaded = await service.loadTokens() + expect(loaded?.refreshToken).toBe('refresh-to-preserve') + expect(loaded?.idToken).toBe('id-token-to-preserve') + expect(loaded?.clientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann') + }) + test('returns null when refresh fails', async () => { await service.saveTokens({ accessToken: 'expired', diff --git a/src/server/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index 6e718583..b9dde1af 100644 --- a/src/server/services/hahaOpenAIOAuthService.ts +++ b/src/server/services/hahaOpenAIOAuthService.ts @@ -23,20 +23,20 @@ import { refreshOpenAITokens, isOpenAITokenExpired, normalizeOpenAITokens, + withRefreshedAccessToken, OPENAI_CODEX_OAUTH_PORT, OPENAI_CODEX_REDIRECT_PATH, } from '../../services/openaiAuth/client.js' -import type { - OpenAIOAuthTokens, - OpenAIOAuthTokenResponse, -} from '../../services/openaiAuth/types.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 = { @@ -53,6 +53,12 @@ type OpenAIRefreshFn = ( 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 @@ -61,10 +67,8 @@ export class HahaOpenAIOAuthService { this.refreshFn = fn } - private getOAuthFilePath(): string { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') - return path.join(configDir, 'cc-haha', 'openai-oauth.json') + getOAuthFilePath(): string { + return getHahaOpenAIOAuthFilePath() } async loadTokens(): Promise { @@ -161,8 +165,10 @@ export class HahaOpenAIOAuthService { 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 @@ -180,13 +186,26 @@ export class HahaOpenAIOAuthService { try { const refreshed = await this.refreshFn(tokens.refreshToken) - const normalized = normalizeOpenAITokens(refreshed) + 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 ?? tokens.refreshToken, + refreshToken: normalized.refreshToken, expiresAt: normalized.expiresAt, - email: normalized.email ?? tokens.email, - accountId: normalized.accountId ?? tokens.accountId, + idToken: normalized.idToken ?? null, + email: normalized.email ?? null, + accountId: normalized.accountId ?? null, + clientId: normalized.clientId ?? null, } await this.saveTokens(updated) return updated diff --git a/src/services/openaiAuth/client.ts b/src/services/openaiAuth/client.ts index 23a0e7f0..e83a148c 100644 --- a/src/services/openaiAuth/client.ts +++ b/src/services/openaiAuth/client.ts @@ -69,6 +69,7 @@ export async function refreshOpenAITokens( grant_type: 'refresh_token', refresh_token: refreshToken, client_id: OPENAI_CODEX_CLIENT_ID, + scope: 'openid profile email', }).toString(), }) @@ -112,6 +113,10 @@ export function normalizeOpenAITokens( parseOpenAIJwtClaims(response.id_token) ?? parseOpenAIJwtClaims(response.access_token) + if (!response.refresh_token) { + throw new Error('OpenAI OAuth response did not include a refresh token') + } + return { accessToken: response.access_token, refreshToken: response.refresh_token, @@ -119,6 +124,7 @@ export function normalizeOpenAITokens( idToken: response.id_token, accountId: extractOpenAIAccountId(claims), email: claims?.email, + clientId: OPENAI_CODEX_CLIENT_ID, } } @@ -130,12 +136,17 @@ export function withRefreshedAccessToken( existing: OpenAIOAuthTokens, refreshed: OpenAIOAuthTokenResponse, ): OpenAIOAuthTokens { - const next = normalizeOpenAITokens(refreshed) + const claims = + parseOpenAIJwtClaims(refreshed.id_token) ?? + parseOpenAIJwtClaims(refreshed.access_token) return { - ...next, - accountId: next.accountId ?? existing.accountId, - email: next.email ?? existing.email, - idToken: next.idToken ?? existing.idToken, + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? existing.refreshToken, + expiresAt: Date.now() + (refreshed.expires_in ?? 3600) * 1000, + idToken: refreshed.id_token ?? existing.idToken, + accountId: extractOpenAIAccountId(claims) ?? existing.accountId, + email: claims?.email ?? existing.email, + clientId: existing.clientId ?? OPENAI_CODEX_CLIENT_ID, } } diff --git a/src/services/openaiAuth/storage.test.ts b/src/services/openaiAuth/storage.test.ts new file mode 100644 index 00000000..fd080869 --- /dev/null +++ b/src/services/openaiAuth/storage.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'fs' +import * as fsp from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { + clearOpenAIOAuthTokenCache, + deleteOpenAIOAuthTokens, + getOpenAIOAuthTokens, + getOpenAIOAuthTokensAsync, + saveOpenAIOAuthTokens, +} from './storage.js' + +describe('OpenAI OAuth desktop token file storage', () => { + let tmpDir: string + let tokenPath: string + let originalTokenFile: string | undefined + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'openai-oauth-storage-')) + tokenPath = path.join(tmpDir, 'openai-oauth.json') + originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE + process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath + clearOpenAIOAuthTokenCache() + }) + + afterEach(async () => { + if (originalTokenFile === undefined) { + delete process.env.OPENAI_CODEX_OAUTH_FILE + } else { + process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile + } + clearOpenAIOAuthTokenCache() + await fsp.rm(tmpDir, { recursive: true, force: true }) + }) + + test('reads desktop token file synchronously', async () => { + await fsp.writeFile( + tokenPath, + JSON.stringify({ + accessToken: 'desktop-access', + refreshToken: 'desktop-refresh', + expiresAt: 4_100_000_000_000, + idToken: 'desktop-id-token', + email: 'user@example.com', + accountId: 'acct_desktop', + }), + 'utf-8', + ) + + const tokens = getOpenAIOAuthTokens() + + expect(tokens).toMatchObject({ + accessToken: 'desktop-access', + refreshToken: 'desktop-refresh', + expiresAt: 4_100_000_000_000, + idToken: 'desktop-id-token', + email: 'user@example.com', + accountId: 'acct_desktop', + }) + }) + + test('reads desktop token file asynchronously', async () => { + await fsp.writeFile( + tokenPath, + JSON.stringify({ + accessToken: 'async-access', + refreshToken: 'async-refresh', + expiresAt: 4_100_000_000_000, + email: null, + accountId: null, + }), + 'utf-8', + ) + + const tokens = await getOpenAIOAuthTokensAsync() + + expect(tokens?.accessToken).toBe('async-access') + expect(tokens?.refreshToken).toBe('async-refresh') + }) + + test('writes refreshed tokens back to the desktop token file', async () => { + const result = saveOpenAIOAuthTokens({ + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh', + expiresAt: 4_100_000_000_000, + idToken: 'fresh-id-token', + email: 'fresh@example.com', + accountId: 'acct_fresh', + }) + + expect(result).toEqual({ success: true }) + const raw = JSON.parse( + fs.readFileSync(tokenPath, 'utf-8'), + ) as Record + expect(raw).toMatchObject({ + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh', + idToken: 'fresh-id-token', + email: 'fresh@example.com', + accountId: 'acct_fresh', + }) + if (process.platform !== 'win32') { + expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600) + } + }) + + test('deletes the desktop token file when the env override is set', async () => { + await fsp.writeFile( + tokenPath, + JSON.stringify({ + accessToken: 'desktop-access', + refreshToken: 'desktop-refresh', + expiresAt: 4_100_000_000_000, + }), + 'utf-8', + ) + + expect(deleteOpenAIOAuthTokens()).toBe(true) + expect(fs.existsSync(tokenPath)).toBe(false) + }) +}) diff --git a/src/services/openaiAuth/storage.ts b/src/services/openaiAuth/storage.ts index 93f9b0a6..666085ab 100644 --- a/src/services/openaiAuth/storage.ts +++ b/src/services/openaiAuth/storage.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs' +import * as path from 'path' import memoize from 'lodash-es/memoize.js' import { getSecureStorage } from '../../utils/secureStorage/index.js' import { errorMessage } from '../../utils/errors.js' @@ -5,16 +7,94 @@ import { logError } from '../../utils/log.js' import type { OpenAIOAuthTokens } from './types.js' const STORAGE_KEY = 'openaiCodexOauth' +export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE' type SecureStorageShape = Record & { openaiCodexOauth?: OpenAIOAuthTokens } +function getDesktopTokenFilePath(): string | null { + const filePath = process.env[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]?.trim() + return filePath ? filePath : null +} + +function normalizeTokenFile(value: unknown): OpenAIOAuthTokens | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + + const record = value as Record + if ( + typeof record.accessToken !== 'string' || + typeof record.refreshToken !== 'string' || + typeof record.expiresAt !== 'number' + ) { + return null + } + + return { + accessToken: record.accessToken, + refreshToken: record.refreshToken, + expiresAt: record.expiresAt, + ...(typeof record.idToken === 'string' && { idToken: record.idToken }), + ...(typeof record.accountId === 'string' && { + accountId: record.accountId, + }), + ...(typeof record.email === 'string' && { email: record.email }), + ...(typeof record.clientId === 'string' && { clientId: record.clientId }), + } +} + +function readDesktopTokenFileSync(): OpenAIOAuthTokens | null { + const filePath = getDesktopTokenFilePath() + if (!filePath) return null + + try { + return normalizeTokenFile(JSON.parse(fs.readFileSync(filePath, 'utf-8'))) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logError(error) + } + return null + } +} + +async function readDesktopTokenFileAsync(): Promise { + const filePath = getDesktopTokenFilePath() + if (!filePath) return null + + try { + const raw = await fs.promises.readFile(filePath, 'utf-8') + return normalizeTokenFile(JSON.parse(raw)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logError(error) + } + return null + } +} + +function writeDesktopTokenFileSync(tokens: OpenAIOAuthTokens): boolean { + const filePath = getDesktopTokenFilePath() + if (!filePath) return false + + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}` + fs.writeFileSync(tmpFile, JSON.stringify(tokens, null, 2) + '\n', { + mode: 0o600, + }) + fs.renameSync(tmpFile, filePath) + return true +} + export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { success: boolean warning?: string } { try { + if (writeDesktopTokenFileSync(tokens)) { + clearOpenAIOAuthTokenCache() + return { success: true } + } + const storage = getSecureStorage() const data = (storage.read() ?? {}) as SecureStorageShape data[STORAGE_KEY] = tokens @@ -31,6 +111,9 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { } export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => { + const desktopTokens = readDesktopTokenFileSync() + if (desktopTokens) return desktopTokens + try { const storage = getSecureStorage() const data = storage.read() as SecureStorageShape | null @@ -42,6 +125,9 @@ export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => { }) export async function getOpenAIOAuthTokensAsync(): Promise { + const desktopTokens = await readDesktopTokenFileAsync() + if (desktopTokens) return desktopTokens + try { const storage = getSecureStorage() const data = (await storage.readAsync()) as SecureStorageShape | null @@ -58,6 +144,13 @@ export function clearOpenAIOAuthTokenCache(): void { export function deleteOpenAIOAuthTokens(): boolean { try { + const filePath = getDesktopTokenFilePath() + if (filePath) { + fs.rmSync(filePath, { force: true }) + clearOpenAIOAuthTokenCache() + return true + } + const storage = getSecureStorage() const data = (storage.read() ?? {}) as SecureStorageShape delete data[STORAGE_KEY] diff --git a/src/services/openaiAuth/types.ts b/src/services/openaiAuth/types.ts index 6a2d9e5e..8c558f12 100644 --- a/src/services/openaiAuth/types.ts +++ b/src/services/openaiAuth/types.ts @@ -1,8 +1,10 @@ export type OpenAIOAuthTokenResponse = { access_token: string - refresh_token: string + refresh_token?: string id_token?: string expires_in?: number + scope?: string + token_type?: string } export type OpenAIOAuthTokens = { @@ -12,6 +14,7 @@ export type OpenAIOAuthTokens = { idToken?: string accountId?: string email?: string + clientId?: string } export type OpenAIJwtClaims = {