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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 22:56:26 +08:00
parent 238ab65cf3
commit 0927c80cdc
6 changed files with 295 additions and 19 deletions

View File

@ -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',

View File

@ -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<string, OpenAIOAuthSession>()
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<StoredOpenAIOAuthTokens | null> {
@ -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

View File

@ -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,
}
}

View File

@ -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<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('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)
})
})

View File

@ -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<string, unknown> & {
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<string, unknown>
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<OpenAIOAuthTokens | null> {
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<OpenAIOAuthTokens | null> {
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]

View File

@ -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 = {