From 22c2e203eeeaee46e8a99aa1e12c093f3248549b 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 23:21:06 +0800 Subject: [PATCH] Prevent legacy OpenAI OAuth token resurrection File-backed desktop OpenAI OAuth must not later fall through to stale secure-storage credentials when a process launches without OPENAI_CODEX_OAUTH_FILE. The file-backed save and delete paths now leave a local marker that disables legacy secure-storage reads for that config directory, while an explicit secure-storage save clears the marker. Constraint: Avoid synchronous macOS keychain work on the file-backed token save path. Rejected: Delete secure-storage credentials during file-backed save | it adds keychain latency and can block the desktop OAuth write path. Confidence: high Scope-risk: narrow Tested: bun test src/services/openaiAuth/storage.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts src/server/__tests__/haha-openai-oauth-api.test.ts Tested: git diff --check --- src/services/openaiAuth/storage.test.ts | 30 ++++++++++++++++ src/services/openaiAuth/storage.ts | 48 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/services/openaiAuth/storage.test.ts b/src/services/openaiAuth/storage.test.ts index 1c732d57..340daf4c 100644 --- a/src/services/openaiAuth/storage.test.ts +++ b/src/services/openaiAuth/storage.test.ts @@ -74,6 +74,11 @@ describe('OpenAI OAuth desktop token file storage', () => { clearOpenAIOAuthTokenCache() } + function unsetTokenFileOverride(): void { + delete process.env.OPENAI_CODEX_OAUTH_FILE + clearOpenAIOAuthTokenCache() + } + test('reads desktop token file synchronously', async () => { await fsp.writeFile( tokenPath, @@ -145,6 +150,27 @@ describe('OpenAI OAuth desktop token file storage', () => { } }) + test('file-backed save clears legacy secure storage tokens', async () => { + seedSecureStorage({ + accessToken: 'secure-access', + refreshToken: 'secure-refresh', + expiresAt: 4_100_000_000_000, + }) + + const result = saveOpenAIOAuthTokens({ + accessToken: 'file-access', + refreshToken: 'file-refresh', + expiresAt: 4_100_000_000_123, + }) + + expect(result).toEqual({ success: true }) + expect(getOpenAIOAuthTokens()?.accessToken).toBe('file-access') + + unsetTokenFileOverride() + expect(getOpenAIOAuthTokens()).toBeNull() + await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() + }) + test('deletes the desktop token file when the env override is set', async () => { await fsp.writeFile( tokenPath, @@ -202,6 +228,10 @@ describe('OpenAI OAuth desktop token file storage', () => { expect(deleteOpenAIOAuthTokens()).toBe(true) expect(getOpenAIOAuthTokens()).toBeNull() await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() + + unsetTokenFileOverride() + expect(getOpenAIOAuthTokens()).toBeNull() + await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() }) test('reloads sync tokens when OPENAI_CODEX_OAUTH_FILE changes without clearing cache', async () => { diff --git a/src/services/openaiAuth/storage.ts b/src/services/openaiAuth/storage.ts index db2d9a3f..1d85d284 100644 --- a/src/services/openaiAuth/storage.ts +++ b/src/services/openaiAuth/storage.ts @@ -1,4 +1,5 @@ import * as fs from 'fs' +import * as os from 'os' import * as path from 'path' import memoize from 'lodash-es/memoize.js' import { getSecureStorage } from '../../utils/secureStorage/index.js' @@ -15,12 +16,46 @@ type SecureStorageShape = Record & { const SECURE_STORAGE_CACHE_KEY = 'secure-storage' const FILE_CACHE_KEY_PREFIX = 'file:' +const FILE_BACKED_STORAGE_MARKER_FILE = 'openai-oauth-file-backed' function getDesktopTokenFilePath(): string | null { const filePath = process.env[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]?.trim() return filePath ? filePath : null } +function getCcHahaDir(): string { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + return path.join(configDir, 'cc-haha') +} + +function getFileBackedStorageMarkerPath(): string { + return path.join(getCcHahaDir(), FILE_BACKED_STORAGE_MARKER_FILE) +} + +function markFileBackedStorageUsed(): void { + try { + fs.mkdirSync(getCcHahaDir(), { recursive: true }) + fs.writeFileSync(getFileBackedStorageMarkerPath(), '1\n', { mode: 0o600 }) + } catch (error) { + logError(error) + } +} + +function clearFileBackedStorageMarker(): void { + try { + fs.rmSync(getFileBackedStorageMarkerPath(), { force: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logError(error) + } + } +} + +function isSecureStorageFallbackDisabled(): boolean { + return fs.existsSync(getFileBackedStorageMarkerPath()) +} + function normalizeTokenFile(value: unknown): OpenAIOAuthTokens | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null @@ -109,6 +144,7 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { } { try { if (writeDesktopTokenFileSync(tokens)) { + markFileBackedStorageUsed() clearOpenAIOAuthTokenCache() return { success: true } } @@ -117,6 +153,9 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { const data = (storage.read() ?? {}) as SecureStorageShape data[STORAGE_KEY] = tokens const result = storage.update(data) + if (result.success) { + clearFileBackedStorageMarker() + } clearOpenAIOAuthTokenCache() return result } catch (error) { @@ -136,6 +175,10 @@ const getOpenAIOAuthTokensCached = memoize( ) } + if (isSecureStorageFallbackDisabled()) { + return null + } + try { const storage = getSecureStorage() const data = storage.read() as SecureStorageShape | null @@ -160,6 +203,10 @@ export async function getOpenAIOAuthTokensAsync(): Promise