mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
238ab65cf3
commit
0927c80cdc
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
122
src/services/openaiAuth/storage.test.ts
Normal file
122
src/services/openaiAuth/storage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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]
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user