Files
orcs-code/src/utils/codexCredentials.ts
Henrique Fernandes fc7dc9ca0d Add Codex OAuth provider flow for ChatGPT account sign-in (#503)
* feat: add Codex OAuth provider flow

* fix: harden Codex OAuth storage, session activation, and UI
2026-04-13 22:34:16 +08:00

376 lines
9.8 KiB
TypeScript

import { isBareMode } from './envUtils.js'
import { getSecureStorage } from './secureStorage/index.js'
import {
asTrimmedString,
CODEX_REFRESH_URL,
exchangeCodexIdTokenForApiKey,
getCodexOAuthClientId,
parseChatgptAccountId,
decodeJwtPayload,
} from '../services/api/codexOAuthShared.js'
export const CODEX_STORAGE_KEY = 'codex' as const
const CODEX_TOKEN_REFRESH_SKEW_MS = 60_000
const CODEX_TOKEN_REFRESH_RETRY_COOLDOWN_MS = 60_000
export type CodexCredentialBlob = {
apiKey?: string
accessToken: string
refreshToken?: string
idToken?: string
accountId?: string
profileId?: string
lastRefreshAt?: number
lastRefreshFailureAt?: number
}
type CodexTokenRefreshResponse = {
access_token?: string
refresh_token?: string
id_token?: string
}
let inFlightCodexRefresh:
| Promise<{
refreshed: boolean
credentials?: CodexCredentialBlob
}>
| null = null
let inMemoryLastRefreshFailureAt: number | null = null
function getCodexSecureStorage() {
return getSecureStorage({ allowPlainTextFallback: false })
}
function parseJwtExpiryMs(token: string | undefined): number | undefined {
if (!token) return undefined
const payload = decodeJwtPayload(token)
const exp = payload?.exp
if (typeof exp === 'number' && Number.isFinite(exp)) {
return exp * 1000
}
return undefined
}
function normalizeCodexCredentialBlob(
value: unknown,
): CodexCredentialBlob | undefined {
if (!value || typeof value !== 'object') return undefined
const record = value as Record<string, unknown>
const apiKey = asTrimmedString(record.apiKey)
const accessToken = asTrimmedString(record.accessToken)
if (!accessToken) return undefined
const refreshToken = asTrimmedString(record.refreshToken)
const idToken = asTrimmedString(record.idToken)
const accountId =
asTrimmedString(record.accountId) ??
parseChatgptAccountId(idToken) ??
parseChatgptAccountId(accessToken)
const profileId = asTrimmedString(record.profileId)
const lastRefreshAt =
typeof record.lastRefreshAt === 'number' &&
Number.isFinite(record.lastRefreshAt)
? record.lastRefreshAt
: undefined
const lastRefreshFailureAt =
typeof record.lastRefreshFailureAt === 'number' &&
Number.isFinite(record.lastRefreshFailureAt)
? record.lastRefreshFailureAt
: undefined
return {
apiKey,
accessToken,
refreshToken,
idToken,
accountId,
profileId,
lastRefreshAt,
lastRefreshFailureAt,
}
}
function shouldRefreshCodexToken(blob: CodexCredentialBlob): boolean {
const expiresAt =
parseJwtExpiryMs(blob.accessToken) ?? parseJwtExpiryMs(blob.idToken)
if (expiresAt === undefined) {
return false
}
return expiresAt <= Date.now() + CODEX_TOKEN_REFRESH_SKEW_MS
}
function isWithinRefreshFailureCooldown(
blob: CodexCredentialBlob,
now = Date.now(),
): boolean {
const lastRefreshFailureAt = Math.max(
blob.lastRefreshFailureAt ?? 0,
inMemoryLastRefreshFailureAt ?? 0,
)
if (!lastRefreshFailureAt) {
return false
}
return (
now - lastRefreshFailureAt < CODEX_TOKEN_REFRESH_RETRY_COOLDOWN_MS
)
}
function getRefreshErrorMessage(
status: number,
bodyText: string,
): string {
if (!bodyText.trim()) {
return `Codex token refresh failed with status ${status}.`
}
try {
const parsed = JSON.parse(bodyText) as Record<string, unknown>
const nestedError =
parsed.error && typeof parsed.error === 'object'
? (parsed.error as Record<string, unknown>)
: undefined
const code = asTrimmedString(nestedError?.code ?? parsed.code)
const message =
asTrimmedString(nestedError?.message ?? parsed.error_description) ??
bodyText.trim()
return code
? `Codex token refresh failed (${code}): ${message}`
: `Codex token refresh failed with status ${status}: ${message}`
} catch {
return `Codex token refresh failed with status ${status}: ${bodyText.trim()}`
}
}
export function readCodexCredentials(): CodexCredentialBlob | undefined {
if (isBareMode()) return undefined
try {
const data = getCodexSecureStorage().read()
return normalizeCodexCredentialBlob(data?.codex)
} catch {
return undefined
}
}
export async function readCodexCredentialsAsync(): Promise<
CodexCredentialBlob | undefined
> {
if (isBareMode()) return undefined
try {
const data = await getCodexSecureStorage().readAsync()
return normalizeCodexCredentialBlob(data?.codex)
} catch {
return undefined
}
}
export function isCodexRefreshFailureCoolingDown(
blob: Pick<CodexCredentialBlob, 'lastRefreshFailureAt'>,
now = Date.now(),
): boolean {
return isWithinRefreshFailureCooldown(
blob as CodexCredentialBlob,
now,
)
}
export function saveCodexCredentials(
credentials: CodexCredentialBlob,
): { success: boolean; warning?: string } {
if (isBareMode()) {
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
}
const normalized = normalizeCodexCredentialBlob(credentials)
if (!normalized) {
return { success: false, warning: 'Codex credentials are incomplete.' }
}
const secureStorage = getCodexSecureStorage()
const previous = secureStorage.read() || {}
const previousCodex = normalizeCodexCredentialBlob(previous[CODEX_STORAGE_KEY])
const next = {
...(previous as Record<string, unknown>),
[CODEX_STORAGE_KEY]: {
...normalized,
profileId: normalized.profileId ?? previousCodex?.profileId,
lastRefreshAt: normalized.lastRefreshAt ?? Date.now(),
},
}
const result = secureStorage.update(next as typeof previous)
if (result.success) {
const storedCodex = normalizeCodexCredentialBlob(next[CODEX_STORAGE_KEY])
inMemoryLastRefreshFailureAt = storedCodex?.lastRefreshFailureAt ?? null
}
return result
}
export function attachCodexProfileIdToStoredCredentials(profileId: string): {
success: boolean
warning?: string
} {
if (isBareMode()) {
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
}
const current = readCodexCredentials()
if (!current) {
return {
success: false,
warning: 'Codex credentials are not stored securely yet.',
}
}
return saveCodexCredentials({
...current,
profileId,
})
}
function persistCodexRefreshFailure(
credentials: CodexCredentialBlob,
occurredAt: number,
): void {
const result = saveCodexCredentials({
...credentials,
lastRefreshFailureAt: occurredAt,
})
if (!result.success) {
inMemoryLastRefreshFailureAt = occurredAt
}
}
export function clearCodexCredentials(): {
success: boolean
warning?: string
} {
if (isBareMode()) {
return { success: true }
}
const secureStorage = getCodexSecureStorage()
const previous = secureStorage.read() || {}
const next = { ...(previous as Record<string, unknown>) }
delete next[CODEX_STORAGE_KEY]
const result = secureStorage.update(next as typeof previous)
if (result.success) {
inMemoryLastRefreshFailureAt = null
}
return result
}
export async function refreshCodexAccessTokenIfNeeded(options?: {
force?: boolean
}): Promise<{
refreshed: boolean
credentials?: CodexCredentialBlob
}> {
if (isBareMode()) {
return { refreshed: false }
}
if (process.env.CODEX_API_KEY?.trim()) {
return { refreshed: false }
}
const current = await readCodexCredentialsAsync()
if (!current) {
return { refreshed: false }
}
if (!current.refreshToken) {
return { refreshed: false, credentials: current }
}
if (!options?.force && !shouldRefreshCodexToken(current)) {
return { refreshed: false, credentials: current }
}
if (!options?.force && isWithinRefreshFailureCooldown(current)) {
return { refreshed: false, credentials: current }
}
if (inFlightCodexRefresh) {
return inFlightCodexRefresh
}
inFlightCodexRefresh = (async () => {
const refreshAttemptedAt = Date.now()
try {
const body = new URLSearchParams({
client_id: getCodexOAuthClientId(),
grant_type: 'refresh_token',
refresh_token: current.refreshToken,
})
const response = await fetch(CODEX_REFRESH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
const bodyText = await response.text().catch(() => '')
throw new Error(getRefreshErrorMessage(response.status, bodyText))
}
const payload = (await response.json()) as CodexTokenRefreshResponse
const accessToken = asTrimmedString(payload.access_token)
if (!accessToken) {
throw new Error(
'Codex token refresh succeeded without a new access token.',
)
}
const next: CodexCredentialBlob = {
accessToken,
refreshToken:
asTrimmedString(payload.refresh_token) ?? current.refreshToken,
idToken: asTrimmedString(payload.id_token) ?? current.idToken,
accountId:
parseChatgptAccountId(payload.id_token) ??
parseChatgptAccountId(payload.access_token) ??
current.accountId,
lastRefreshAt: Date.now(),
}
const idTokenForExchange = next.idToken ?? current.idToken
if (idTokenForExchange) {
next.apiKey = await exchangeCodexIdTokenForApiKey(
idTokenForExchange,
).catch(() => undefined)
}
const saveResult = saveCodexCredentials(next)
if (!saveResult.success) {
throw new Error(
saveResult.warning ??
'Codex token refresh succeeded but credentials could not be saved.',
)
}
return {
refreshed: true,
credentials: next,
}
} catch (error) {
persistCodexRefreshFailure(current, refreshAttemptedAt)
throw error
} finally {
inFlightCodexRefresh = null
}
})()
return inFlightCodexRefresh
}