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
This commit is contained in:
committed by
GitHub
parent
252808bbd0
commit
fc7dc9ca0d
375
src/utils/codexCredentials.ts
Normal file
375
src/utils/codexCredentials.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user