cc-haha/src/services/openaiAuth/storage.test.ts
程序员阿江(Relakkes) 22c2e203ee 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
2026-05-18 23:21:06 +08:00

321 lines
9.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, spyOn, 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'
import { plainTextStorage } from '../../utils/secureStorage/plainTextStorage.js'
import type { OpenAIOAuthTokens } from './types.js'
describe('OpenAI OAuth desktop token file storage', () => {
let tmpDir: string
let tokenPath: string
let originalTokenFile: string | undefined
let originalConfigDir: string | undefined
let originalHome: string | undefined
let originalUserProfile: string | undefined
let originalCwd: string
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
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalHome = process.env.HOME
originalUserProfile = process.env.USERPROFILE
originalCwd = process.cwd()
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.HOME = tmpDir
process.env.USERPROFILE = tmpDir
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath
clearOpenAIOAuthTokenCache()
})
afterEach(async () => {
plainTextStorage.delete()
if (originalTokenFile === undefined) {
delete process.env.OPENAI_CODEX_OAUTH_FILE
} else {
process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile
}
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
process.chdir(originalCwd)
clearOpenAIOAuthTokenCache()
await fsp.rm(tmpDir, { recursive: true, force: true })
})
function seedSecureStorage(tokens: OpenAIOAuthTokens): void {
delete process.env.OPENAI_CODEX_OAUTH_FILE
clearOpenAIOAuthTokenCache()
expect(
plainTextStorage.update({ openaiCodexOauth: tokens }).success,
).toBe(true)
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath
clearOpenAIOAuthTokenCache()
}
function unsetTokenFileOverride(): void {
delete process.env.OPENAI_CODEX_OAUTH_FILE
clearOpenAIOAuthTokenCache()
}
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<string, unknown>
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('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,
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)
})
test('returns null from both getters when env override is set but file is missing', async () => {
seedSecureStorage({
accessToken: 'secure-access',
refreshToken: 'secure-refresh',
expiresAt: 4_100_000_000_000,
})
expect(getOpenAIOAuthTokens()).toBeNull()
await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull()
})
test('returns null from both getters when env override file contains corrupt json', async () => {
seedSecureStorage({
accessToken: 'secure-access',
refreshToken: 'secure-refresh',
expiresAt: 4_100_000_000_000,
})
await fsp.writeFile(tokenPath, '{ definitely-not-json', 'utf-8')
expect(getOpenAIOAuthTokens()).toBeNull()
await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull()
})
test('does not fall back to secure storage after deleting env override file', async () => {
seedSecureStorage({
accessToken: 'secure-access',
refreshToken: 'secure-refresh',
expiresAt: 4_100_000_000_000,
})
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(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 () => {
const tokenPathA = path.join(tmpDir, 'openai-oauth-a.json')
const tokenPathB = path.join(tmpDir, 'openai-oauth-b.json')
await fsp.writeFile(
tokenPathA,
JSON.stringify({
accessToken: 'access-a',
refreshToken: 'refresh-a',
expiresAt: 4_100_000_000_001,
}),
'utf-8',
)
await fsp.writeFile(
tokenPathB,
JSON.stringify({
accessToken: 'access-b',
refreshToken: 'refresh-b',
expiresAt: 4_100_000_000_002,
}),
'utf-8',
)
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPathA
expect(getOpenAIOAuthTokens()?.accessToken).toBe('access-a')
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPathB
expect(getOpenAIOAuthTokens()?.accessToken).toBe('access-b')
})
test('prefers env-pinned file authority when OPENAI_CODEX_OAUTH_FILE matches the secure-storage sentinel', async () => {
const sentinelPath = '__secure-storage__'
seedSecureStorage({
accessToken: 'secure-access',
refreshToken: 'secure-refresh',
expiresAt: 4_100_000_000_000,
})
process.chdir(tmpDir)
process.env.OPENAI_CODEX_OAUTH_FILE = sentinelPath
await fsp.writeFile(
path.join(tmpDir, sentinelPath),
JSON.stringify({
accessToken: 'file-access',
refreshToken: 'file-refresh',
expiresAt: 4_100_000_000_123,
}),
'utf-8',
)
clearOpenAIOAuthTokenCache()
expect(getOpenAIOAuthTokens()).toMatchObject({
accessToken: 'file-access',
refreshToken: 'file-refresh',
expiresAt: 4_100_000_000_123,
})
await expect(getOpenAIOAuthTokensAsync()).resolves.toMatchObject({
accessToken: 'file-access',
refreshToken: 'file-refresh',
expiresAt: 4_100_000_000_123,
})
})
test('cleans up tmp file if desktop token rename fails', async () => {
const renameSyncSpy = spyOn(fs, 'renameSync').mockImplementation(() => {
const error = new Error('rename failed') as NodeJS.ErrnoException
error.code = 'EXDEV'
throw error
})
const result = saveOpenAIOAuthTokens({
accessToken: 'fresh-access',
refreshToken: 'fresh-refresh',
expiresAt: 4_100_000_000_000,
})
renameSyncSpy.mockRestore()
expect(result.success).toBe(false)
const tmpFiles = (await fsp.readdir(tmpDir)).filter((name) =>
name.startsWith('openai-oauth.json.tmp.'),
)
expect(tmpFiles).toEqual([])
})
})