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:
Henrique Fernandes
2026-04-13 11:34:16 -03:00
committed by GitHub
parent 252808bbd0
commit fc7dc9ca0d
34 changed files with 5187 additions and 508 deletions

View File

@@ -0,0 +1,607 @@
/**
* These tests avoid static imports so Bun can mock secureStorage before
* codexCredentials is first loaded.
*/
import { afterEach, describe, expect, mock, test } from 'bun:test'
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
describe('codexCredentials', () => {
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
const originalCodeKey = process.env.CODEX_API_KEY
const originalFetch = globalThis.fetch
afterEach(() => {
mock.restore()
globalThis.fetch = originalFetch
if (originalSimple === undefined) {
delete process.env.CLAUDE_CODE_SIMPLE
} else {
process.env.CLAUDE_CODE_SIMPLE = originalSimple
}
if (originalCodeKey === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = originalCodeKey
}
})
test('save returns failure in bare mode', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
// @ts-expect-error cache-busting query string for Bun module mocks
const { saveCodexCredentials } = await import(
'./codexCredentials.js?save-bare-mode'
)
const result = saveCodexCredentials({
accessToken: 'token',
accountId: 'acct_123',
})
expect(result.success).toBe(false)
expect(result.warning).toContain('Bare mode')
})
test('saveCodexCredentials refuses plaintext fallback when native secure storage is unavailable', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: (options?: { allowPlainTextFallback?: boolean }) => {
expect(options?.allowPlainTextFallback).toBe(false)
return {
read: () => null,
readAsync: async () => null,
update: () => ({
success: false,
warning:
'Secure storage is unavailable on this platform without plaintext fallback.',
}),
delete: () => true,
}
},
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { saveCodexCredentials } = await import(
'./codexCredentials.js?save-no-plaintext-fallback'
)
const result = saveCodexCredentials({
accessToken: 'token',
accountId: 'acct_123',
})
expect(result.success).toBe(false)
expect(result.warning).toContain('without plaintext fallback')
})
test('refreshCodexAccessTokenIfNeeded refreshes expired stored credentials', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CODEX_API_KEY
const expiredToken = makeJwt({
exp: Math.floor((Date.now() - 60_000) / 1000),
chatgpt_account_id: 'acct_old',
})
const freshAccessToken = makeJwt({
exp: Math.floor((Date.now() + 3_600_000) / 1000),
chatgpt_account_id: 'acct_new',
})
const freshIdToken = makeJwt({
exp: Math.floor((Date.now() + 3_600_000) / 1000),
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_new',
},
})
let storageState: Record<string, unknown> = {
codex: {
accessToken: expiredToken,
refreshToken: 'refresh-old',
accountId: 'acct_old',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => storageState,
readAsync: async () => storageState,
update: (next: Record<string, unknown>) => {
storageState = next
return { success: true }
},
}),
}))
globalThis.fetch = mock(
async (_input, init) => {
const bodyText =
typeof init?.body === 'string'
? init.body
: init?.body instanceof URLSearchParams
? init.body.toString()
: ''
if (
bodyText.includes('grant_type=refresh_token') ||
bodyText.includes('"grant_type":"refresh_token"')
) {
return new Response(
JSON.stringify({
access_token: freshAccessToken,
refresh_token: 'refresh-new',
id_token: freshIdToken,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}
return new Response(
JSON.stringify({
access_token: 'codex-api-key-token',
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
},
) as unknown as typeof fetch
// @ts-expect-error cache-busting query string for Bun module mocks
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
await import('./codexCredentials.js?refresh-success')
const result = await refreshCodexAccessTokenIfNeeded()
expect(result.refreshed).toBe(true)
const stored = readCodexCredentials()
expect(stored?.accessToken).toBe(freshAccessToken)
expect(stored?.apiKey).toBe('codex-api-key-token')
expect(stored?.refreshToken).toBe('refresh-new')
expect(stored?.accountId).toBe('acct_new')
})
test('refreshCodexAccessTokenIfNeeded backs off after a failed refresh attempt', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CODEX_API_KEY
const expiredToken = makeJwt({
exp: Math.floor((Date.now() - 60_000) / 1000),
chatgpt_account_id: 'acct_old',
})
let storageState: Record<string, unknown> = {
codex: {
accessToken: expiredToken,
refreshToken: 'refresh-old',
accountId: 'acct_old',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => storageState,
readAsync: async () => storageState,
update: (next: Record<string, unknown>) => {
storageState = next
return { success: true }
},
}),
}))
let refreshAttempts = 0
globalThis.fetch = mock(async () => {
refreshAttempts += 1
return new Response(
JSON.stringify({
error: {
code: 'invalid_grant',
message: 'refresh token expired',
},
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as unknown as typeof fetch
// @ts-expect-error cache-busting query string for Bun module mocks
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
await import('./codexCredentials.js?refresh-cooldown')
await expect(refreshCodexAccessTokenIfNeeded()).rejects.toThrow(
'Codex token refresh failed (invalid_grant): refresh token expired',
)
const afterFailure = readCodexCredentials()
expect(typeof afterFailure?.lastRefreshFailureAt).toBe('number')
const secondAttempt = await refreshCodexAccessTokenIfNeeded()
expect(secondAttempt.refreshed).toBe(false)
expect(secondAttempt.credentials?.accessToken).toBe(expiredToken)
expect(refreshAttempts).toBe(1)
})
test('refreshCodexAccessTokenIfNeeded drops a stale api key when id-token exchange fails', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CODEX_API_KEY
const expiredToken = makeJwt({
exp: Math.floor((Date.now() - 60_000) / 1000),
chatgpt_account_id: 'acct_old',
})
const freshAccessToken = makeJwt({
exp: Math.floor((Date.now() + 3_600_000) / 1000),
chatgpt_account_id: 'acct_new',
})
const freshIdToken = makeJwt({
exp: Math.floor((Date.now() + 3_600_000) / 1000),
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_new',
},
})
let storageState: Record<string, unknown> = {
codex: {
apiKey: 'stale-api-key',
accessToken: expiredToken,
refreshToken: 'refresh-old',
accountId: 'acct_old',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => storageState,
readAsync: async () => storageState,
update: (next: Record<string, unknown>) => {
storageState = next
return { success: true }
},
}),
}))
globalThis.fetch = mock(
async (_input, init) => {
const bodyText =
typeof init?.body === 'string'
? init.body
: init?.body instanceof URLSearchParams
? init.body.toString()
: ''
if (bodyText.includes('grant_type=refresh_token')) {
return new Response(
JSON.stringify({
access_token: freshAccessToken,
refresh_token: 'refresh-new',
id_token: freshIdToken,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}
return new Response('exchange failed', {
status: 500,
})
},
) as unknown as typeof fetch
// @ts-expect-error cache-busting query string for Bun module mocks
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
await import('./codexCredentials.js?refresh-drop-stale-api-key')
const result = await refreshCodexAccessTokenIfNeeded()
expect(result.refreshed).toBe(true)
const stored = readCodexCredentials()
expect(stored?.accessToken).toBe(freshAccessToken)
expect(stored?.apiKey).toBeUndefined()
expect(stored?.refreshToken).toBe('refresh-new')
expect(stored?.accountId).toBe('acct_new')
})
test('refreshCodexAccessTokenIfNeeded deduplicates concurrent refresh attempts', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CODEX_API_KEY
const expiredToken = makeJwt({
exp: Math.floor((Date.now() - 60_000) / 1000),
chatgpt_account_id: 'acct_old',
})
const freshAccessToken = makeJwt({
exp: Math.floor((Date.now() + 3_600_000) / 1000),
chatgpt_account_id: 'acct_new',
})
const freshIdToken = makeJwt({
exp: Math.floor((Date.now() + 3_600_000) / 1000),
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_new',
},
})
let storageState: Record<string, unknown> = {
codex: {
accessToken: expiredToken,
refreshToken: 'refresh-old',
accountId: 'acct_old',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => storageState,
readAsync: async () => storageState,
update: (next: Record<string, unknown>) => {
storageState = next
return { success: true }
},
}),
}))
let refreshAttempts = 0
let releaseRefresh: (() => void) | undefined
const refreshGate = new Promise<void>(resolve => {
releaseRefresh = resolve
})
globalThis.fetch = mock(async (_input, init) => {
const bodyText =
typeof init?.body === 'string'
? init.body
: init?.body instanceof URLSearchParams
? init.body.toString()
: ''
if (bodyText.includes('grant_type=refresh_token')) {
refreshAttempts += 1
await refreshGate
return new Response(
JSON.stringify({
access_token: freshAccessToken,
refresh_token: 'refresh-new',
id_token: freshIdToken,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}
return new Response(
JSON.stringify({
access_token: 'codex-api-key-token',
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as unknown as typeof fetch
// @ts-expect-error cache-busting query string for Bun module mocks
const { refreshCodexAccessTokenIfNeeded } = await import(
'./codexCredentials.js?refresh-dedupe'
)
const firstRefresh = refreshCodexAccessTokenIfNeeded()
const secondRefresh = refreshCodexAccessTokenIfNeeded()
releaseRefresh?.()
const [firstResult, secondResult] = await Promise.all([
firstRefresh,
secondRefresh,
])
expect(refreshAttempts).toBe(1)
expect(firstResult).toEqual(secondResult)
expect(firstResult.refreshed).toBe(true)
expect(firstResult.credentials?.accessToken).toBe(freshAccessToken)
})
test('saveCodexCredentials preserves an existing linked profile id', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
let storageState: Record<string, unknown> = {
codex: {
accessToken: 'access-old',
refreshToken: 'refresh-old',
accountId: 'acct_old',
profileId: 'profile_codex_oauth',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => storageState,
readAsync: async () => storageState,
update: (next: Record<string, unknown>) => {
storageState = next
return { success: true }
},
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { readCodexCredentials, saveCodexCredentials } = await import(
'./codexCredentials.js?preserve-profile-id'
)
const saved = saveCodexCredentials({
accessToken: 'access-new',
refreshToken: 'refresh-new',
accountId: 'acct_new',
})
expect(saved.success).toBe(true)
expect(readCodexCredentials()?.profileId).toBe('profile_codex_oauth')
})
test('attachCodexProfileIdToStoredCredentials links the saved profile id', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
let storageState: Record<string, unknown> = {
codex: {
accessToken: 'access-old',
refreshToken: 'refresh-old',
accountId: 'acct_old',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => storageState,
readAsync: async () => storageState,
update: (next: Record<string, unknown>) => {
storageState = next
return { success: true }
},
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const {
attachCodexProfileIdToStoredCredentials,
readCodexCredentials,
} = await import('./codexCredentials.js?attach-profile-id')
const result =
attachCodexProfileIdToStoredCredentials('profile_codex_oauth')
expect(result.success).toBe(true)
expect(readCodexCredentials()?.profileId).toBe('profile_codex_oauth')
})
test('refreshCodexAccessTokenIfNeeded uses async secure-storage reads in its request path', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CODEX_API_KEY
const freshToken = makeJwt({
exp: Math.floor((Date.now() + 3_600_000) / 1000),
chatgpt_account_id: 'acct_async',
})
let storageState: Record<string, unknown> = {
codex: {
accessToken: freshToken,
refreshToken: 'refresh-async',
accountId: 'acct_async',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => {
throw new Error(
'sync storage read should not run during refresh checks',
)
},
readAsync: async () => storageState,
update: (next: Record<string, unknown>) => {
storageState = next
return { success: true }
},
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { refreshCodexAccessTokenIfNeeded } = await import(
'./codexCredentials.js?refresh-async-read'
)
const result = await refreshCodexAccessTokenIfNeeded()
expect(result.refreshed).toBe(false)
expect(result.credentials?.accessToken).toBe(freshToken)
})
test('refreshCodexAccessTokenIfNeeded keeps a cooldown in memory when secure storage cannot persist it', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CODEX_API_KEY
const expiredToken = makeJwt({
exp: Math.floor((Date.now() - 60_000) / 1000),
chatgpt_account_id: 'acct_old',
})
const storageState: Record<string, unknown> = {
codex: {
accessToken: expiredToken,
refreshToken: 'refresh-old',
accountId: 'acct_old',
},
}
mock.module('./secureStorage/index.js', () => ({
getSecureStorage: () => ({
read: () => storageState,
readAsync: async () => storageState,
update: () => ({
success: false,
warning: 'secure storage unavailable',
}),
}),
}))
let refreshAttempts = 0
globalThis.fetch = mock(async () => {
refreshAttempts += 1
return new Response(
JSON.stringify({
error: {
code: 'invalid_grant',
message: 'refresh token expired',
},
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as unknown as typeof fetch
// @ts-expect-error cache-busting query string for Bun module mocks
const { refreshCodexAccessTokenIfNeeded } = await import(
'./codexCredentials.js?refresh-memory-cooldown'
)
await expect(refreshCodexAccessTokenIfNeeded()).rejects.toThrow(
'Codex token refresh failed (invalid_grant): refresh token expired',
)
const secondAttempt = await refreshCodexAccessTokenIfNeeded()
expect(secondAttempt.refreshed).toBe(false)
expect(secondAttempt.credentials?.accessToken).toBe(expiredToken)
expect(refreshAttempts).toBe(1)
})
})

View 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
}

View File

@@ -0,0 +1,65 @@
import { afterEach, expect, mock, test } from 'bun:test'
afterEach(() => {
mock.restore()
})
async function importFreshEffortModule(options: {
provider: 'codex' | 'openai'
supportsCodexReasoningEffort: boolean
}) {
mock.module('./model/providers.js', () => ({
getAPIProvider: () => options.provider,
}))
mock.module('./model/modelSupportOverrides.js', () => ({
get3PModelCapabilityOverride: () => undefined,
}))
mock.module('../services/api/providerConfig.js', () => ({
supportsCodexReasoningEffort: () => options.supportsCodexReasoningEffort,
}))
return import(`./effort.js?ts=${Date.now()}-${Math.random()}`)
}
test('gpt-5.4 on the ChatGPT Codex backend supports effort selection', async () => {
const { getAvailableEffortLevels, modelSupportsEffort } =
await importFreshEffortModule({
provider: 'codex',
supportsCodexReasoningEffort: true,
})
expect(modelSupportsEffort('gpt-5.4')).toBe(true)
expect(getAvailableEffortLevels('gpt-5.4')).toEqual([
'low',
'medium',
'high',
'xhigh',
])
})
test('gpt-5.4 on the OpenAI provider still supports effort selection', async () => {
const { getAvailableEffortLevels, modelSupportsEffort } =
await importFreshEffortModule({
provider: 'openai',
supportsCodexReasoningEffort: true,
})
expect(modelSupportsEffort('gpt-5.4')).toBe(true)
expect(getAvailableEffortLevels('gpt-5.4')).toEqual([
'low',
'medium',
'high',
'xhigh',
])
})
test('gpt-5.3-codex-spark stays without effort controls', async () => {
const { getAvailableEffortLevels, modelSupportsEffort } =
await importFreshEffortModule({
provider: 'codex',
supportsCodexReasoningEffort: false,
})
expect(modelSupportsEffort('gpt-5.3-codex-spark')).toBe(false)
expect(getAvailableEffortLevels('gpt-5.3-codex-spark')).toEqual([])
})

View File

@@ -5,6 +5,7 @@ import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { getAPIProvider } from './model/providers.js'
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
import { supportsCodexReasoningEffort } from '../services/api/providerConfig.js'
import { isEnvTruthy } from './envUtils.js'
import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js'
@@ -37,6 +38,9 @@ export function modelSupportsEffort(model: string): boolean {
if (supported3P !== undefined) {
return supported3P
}
if (modelUsesOpenAIEffort(model) && supportsCodexReasoningEffort(model)) {
return true
}
// Supported by a subset of Claude 4 models
if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) {
return true
@@ -86,6 +90,9 @@ export function modelUsesOpenAIEffort(model: string): boolean {
}
export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] {
if (!modelSupportsEffort(model)) {
return []
}
if (modelUsesOpenAIEffort(model)) {
return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[]
}

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.ts'
import {
buildStartupEnvFromProfile,
buildAtomicChatProfileEnv,
@@ -12,7 +13,9 @@ import {
buildLaunchEnv,
buildOllamaProfileEnv,
buildOpenAIProfileEnv,
clearPersistedCodexOAuthProfile,
createProfileFile,
isPersistedCodexOAuthProfile,
maskSecretForDisplay,
loadProfileFile,
PROFILE_FILE_NAME,
@@ -23,6 +26,13 @@ import {
type ProfileFile,
} from './providerProfile.ts'
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
function profile(profile: ProfileFile['profile'], env: ProfileFile['env']): ProfileFile {
return {
profile,
@@ -330,6 +340,7 @@ test('codex profiles accept explicit codex credentials', () => {
assert.deepEqual(env, {
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexspark',
CODEX_CREDENTIAL_SOURCE: 'existing',
CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_123',
})
@@ -417,6 +428,77 @@ test('saveProfileFile writes a profile that loadProfileFile can read back', () =
}
})
test('buildCodexProfileEnv tags OAuth-saved profiles so logout can remove them safely', () => {
const env = buildCodexProfileEnv({
model: 'codexplan',
apiKey: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_oauth',
},
}),
credentialSource: 'oauth',
processEnv: {},
})
assert.deepEqual(env, {
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: 'codexplan',
CODEX_CREDENTIAL_SOURCE: 'oauth',
CODEX_API_KEY: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_oauth',
},
}),
CHATGPT_ACCOUNT_ID: 'acct_oauth',
})
})
test('clearPersistedCodexOAuthProfile removes only persisted Codex OAuth profiles', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-codex-oauth-profile-'))
try {
const providerProfileModule = await import(
`./providerProfile.ts?ts=${Date.now()}-${Math.random()}`
)
const {
PROFILE_FILE_NAME,
clearPersistedCodexOAuthProfile,
createProfileFile,
isPersistedCodexOAuthProfile,
loadProfileFile,
saveProfileFile,
} = providerProfileModule
const oauthProfile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
CHATGPT_ACCOUNT_ID: 'acct_oauth',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
saveProfileFile(oauthProfile, { cwd })
assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), true)
assert.equal(
clearPersistedCodexOAuthProfile({ cwd }),
join(cwd, PROFILE_FILE_NAME),
)
assert.equal(loadProfileFile({ cwd }), null)
const existingCredentialProfile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
CHATGPT_ACCOUNT_ID: 'acct_existing',
CODEX_CREDENTIAL_SOURCE: 'existing',
})
saveProfileFile(existingCredentialProfile, { cwd })
assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), false)
assert.equal(clearPersistedCodexOAuthProfile({ cwd }), null)
assert.deepEqual(loadProfileFile({ cwd }), existingCredentialProfile)
} finally {
rmSync(cwd, { recursive: true, force: true })
}
})
test('buildStartupEnvFromProfile applies persisted gemini settings when no provider is explicitly selected', async () => {
const env = await buildStartupEnvFromProfile({
persisted: profile('gemini', {

View File

@@ -7,6 +7,7 @@ import {
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../services/api/providerConfig.ts'
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
import {
getGoalDefaultOpenAIModel,
normalizeRecommendationGoal,
@@ -14,6 +15,20 @@ import {
} from './providerRecommendation.ts'
import { readGeminiAccessToken } from './geminiCredentials.ts'
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
import { getProviderValidationError } from './providerValidation.ts'
import {
maskSecretForDisplay,
redactSecretValueForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.ts'
export {
maskSecretForDisplay,
redactSecretValueForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.ts'
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
export const DEFAULT_GEMINI_BASE_URL =
@@ -33,6 +48,7 @@ const PROFILE_ENV_KEYS = [
'OPENAI_MODEL',
'OPENAI_API_KEY',
'CODEX_API_KEY',
'CODEX_CREDENTIAL_SOURCE',
'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID',
'GEMINI_API_KEY',
@@ -46,21 +62,20 @@ const PROFILE_ENV_KEYS = [
'MISTRAL_MODEL',
] as const
const SECRET_ENV_KEYS = [
'OPENAI_API_KEY',
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'MISTRAL_API_KEY',
] as const
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'mistral'
export type ProviderProfile =
| 'openai'
| 'ollama'
| 'codex'
| 'gemini'
| 'atomic-chat'
| 'mistral'
export type ProfileEnv = {
OPENAI_BASE_URL?: string
OPENAI_MODEL?: string
OPENAI_API_KEY?: string
CODEX_API_KEY?: string
CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing'
CHATGPT_ACCOUNT_ID?: string
CODEX_ACCOUNT_ID?: string
GEMINI_API_KEY?: string
@@ -78,13 +93,6 @@ export type ProfileFile = {
createdAt: string
}
type SecretValueSource = Partial<
Pick<
NodeJS.ProcessEnv & ProfileEnv,
(typeof SECRET_ENV_KEYS)[number]
>
>
type ProfileFileLocation = {
cwd?: string
filePath?: string
@@ -109,102 +117,6 @@ export function isProviderProfile(value: unknown): value is ProviderProfile {
)
}
export function sanitizeApiKey(
key: string | null | undefined,
): string | undefined {
if (!key || key === 'SUA_CHAVE') return undefined
return key
}
function looksLikeSecretValue(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) {
return true
}
if (trimmed.startsWith('AIza')) {
return true
}
return false
}
function collectSecretValues(
sources: Array<SecretValueSource | null | undefined>,
): string[] {
const values = new Set<string>()
for (const source of sources) {
if (!source) continue
for (const key of SECRET_ENV_KEYS) {
const value = sanitizeApiKey(source[key])
if (value) {
values.add(value)
}
}
}
return [...values]
}
export function maskSecretForDisplay(
value: string | null | undefined,
): string | undefined {
const sanitized = sanitizeApiKey(value)
if (!sanitized) return undefined
if (sanitized.length <= 8) {
return 'configured'
}
if (sanitized.startsWith('sk-')) {
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
}
if (sanitized.startsWith('AIza')) {
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
}
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
}
export function redactSecretValueForDisplay(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return trimmed
const secretValues = collectSecretValues(sources)
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
return maskSecretForDisplay(trimmed) ?? 'configured'
}
return trimmed
}
export function sanitizeProviderConfigValue(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const secretValues = collectSecretValues(sources)
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
return undefined
}
return trimmed
}
export function buildOllamaProfileEnv(
model: string,
options: {
@@ -335,6 +247,7 @@ export function buildCodexProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
credentialSource?: 'oauth' | 'existing'
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
@@ -346,10 +259,14 @@ export function buildCodexProfileEnv(options: {
if (!credentials.apiKey || !credentials.accountId) {
return null
}
const credentialSource =
options.credentialSource ??
(credentials.source === 'secure-storage' ? 'oauth' : 'existing')
const env: ProfileEnv = {
OPENAI_BASE_URL: options.baseUrl || DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: options.model || 'codexplan',
CODEX_CREDENTIAL_SOURCE: credentialSource,
}
if (key) {
@@ -399,6 +316,30 @@ export function buildMistralProfileEnv(options: {
return env
}
export function buildCodexOAuthProfileEnv(
tokens: {
accessToken: string
idToken?: string
accountId?: string
},
): ProfileEnv | null {
const accountId =
tokens.accountId ??
parseChatgptAccountId(tokens.idToken) ??
parseChatgptAccountId(tokens.accessToken)
if (!accountId) {
return null
}
return {
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: accountId,
CODEX_CREDENTIAL_SOURCE: 'oauth',
}
}
export function createProfileFile(
profile: ProviderProfile,
env: ProfileEnv,
@@ -410,6 +351,26 @@ export function createProfileFile(
}
}
export function isPersistedCodexOAuthProfile(
persisted: ProfileFile | null,
): boolean {
return (
persisted?.profile === 'codex' &&
persisted.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
)
}
export function clearPersistedCodexOAuthProfile(
options?: ProfileFileLocation,
): string | null {
const persisted = loadProfileFile(options)
if (!isPersistedCodexOAuthProfile(persisted)) {
return null
}
return deleteProfileFile(options)
}
export function loadProfileFile(options?: ProfileFileLocation): ProfileFile | null {
const filePath = resolveProfileFilePath(options)
if (!existsSync(filePath)) {
@@ -545,6 +506,7 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_OPENAI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
env.GEMINI_MODEL =
shellGeminiModel ||
@@ -668,6 +630,7 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_FOUNDRY
delete env.CLAUDE_CODE_USE_GEMINI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
delete env.GEMINI_API_KEY
delete env.GEMINI_AUTH_MODE
delete env.GEMINI_ACCESS_TOKEN
@@ -838,3 +801,40 @@ export function applyProfileEnvToProcessEnv(
Object.assign(targetEnv, nextEnv)
}
export async function applySavedProfileToCurrentSession(options: {
profileFile: ProfileFile
processEnv?: NodeJS.ProcessEnv
}): Promise<string | null> {
const processEnv = options.processEnv ?? process.env
const baseEnv = { ...processEnv }
const isCodexOAuthProfile =
options.profileFile.profile === 'codex' &&
options.profileFile.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
if (isCodexOAuthProfile) {
delete baseEnv.CODEX_API_KEY
delete baseEnv.CODEX_ACCOUNT_ID
delete baseEnv.CHATGPT_ACCOUNT_ID
}
const nextEnv = await buildLaunchEnv({
profile: options.profileFile.profile,
persisted: options.profileFile,
goal: normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
processEnv: baseEnv,
getOllamaChatBaseUrl,
readGeminiAccessToken,
})
const validationError = await getProviderValidationError(nextEnv)
if (validationError) {
return validationError
}
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
applyProfileEnvToProcessEnv(processEnv, nextEnv)
return null
}

View File

@@ -0,0 +1,107 @@
const SECRET_ENV_KEYS = [
'OPENAI_API_KEY',
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'MISTRAL_API_KEY',
] as const
export type SecretValueSource = Partial<
Record<(typeof SECRET_ENV_KEYS)[number], string | undefined>
>
export function sanitizeApiKey(
key: string | null | undefined,
): string | undefined {
if (!key || key === 'SUA_CHAVE') return undefined
return key
}
function looksLikeSecretValue(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) {
return true
}
if (trimmed.startsWith('AIza')) {
return true
}
return false
}
function collectSecretValues(
sources: Array<SecretValueSource | null | undefined>,
): string[] {
const values = new Set<string>()
for (const source of sources) {
if (!source) continue
for (const key of SECRET_ENV_KEYS) {
const value = sanitizeApiKey(source[key])
if (value) {
values.add(value)
}
}
}
return [...values]
}
export function maskSecretForDisplay(
value: string | null | undefined,
): string | undefined {
const sanitized = sanitizeApiKey(value)
if (!sanitized) return undefined
if (sanitized.length <= 8) {
return 'configured'
}
if (sanitized.startsWith('sk-')) {
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
}
if (sanitized.startsWith('AIza')) {
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
}
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
}
export function redactSecretValueForDisplay(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return trimmed
const secretValues = collectSecretValues(sources)
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
return maskSecretForDisplay(trimmed) ?? 'configured'
}
return trimmed
}
export function sanitizeProviderConfigValue(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const secretValues = collectSecretValues(sources)
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
return undefined
}
return trimmed
}

View File

@@ -6,11 +6,13 @@ import {
resolveProviderRequest,
} from '../services/api/providerConfig.js'
import { getGlobalClaudeFile } from './env.js'
import { isBareMode } from './envUtils.js'
import {
type GeminiResolvedCredential,
resolveGeminiCredential,
} from './geminiAuth.js'
import { PROFILE_FILE_NAME, redactSecretValueForDisplay } from './providerProfile.js'
import { PROFILE_FILE_NAME } from './providerProfile.js'
import { redactSecretValueForDisplay } from './providerSecrets.js'
function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false
@@ -82,6 +84,7 @@ export async function getProviderValidationError(
) => Promise<GeminiResolvedCredential>
},
): Promise<string | null> {
const secretSource = env
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
@@ -131,16 +134,17 @@ export async function getProviderValidationError(
if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(env)
if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath
? ` or put auth.json at ${credentials.authPath}`
: ''
? `${oauthHint} or put auth.json at ${credentials.authPath}`
: oauthHint
const safeModel =
redactSecretValueForDisplay(request.requestedModel, env) ??
redactSecretValueForDisplay(request.requestedModel, secretSource) ??
'the requested model'
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
}
if (!credentials.accountId) {
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
}
return null
}

View File

@@ -5,6 +5,16 @@ import { windowsCredentialStorage } from './windowsCredentialStorage.js'
import { plainTextStorage } from './plainTextStorage.js'
export interface SecureStorageData {
codex?: {
apiKey?: string
accessToken: string
refreshToken?: string
idToken?: string
accountId?: string
profileId?: string
lastRefreshAt?: number
lastRefreshFailureAt?: number
}
mcpOAuth?: Record<
string,
{
@@ -36,22 +46,44 @@ export interface SecureStorage {
delete(): boolean
}
const unavailableSecureStorage: SecureStorage = {
name: 'unavailable-secure-storage',
read: () => null,
readAsync: async () => null,
update: () => ({
success: false,
warning:
'Secure storage is unavailable on this platform without plaintext fallback.',
}),
delete: () => true,
}
/**
* Get the appropriate secure storage implementation for the current platform.
* Prefers native OS vaults (Keychain, libsecret, Credential Locker) with a plaintext fallback.
*/
export function getSecureStorage(): SecureStorage {
export function getSecureStorage(options?: {
allowPlainTextFallback?: boolean
}): SecureStorage {
const allowPlainTextFallback = options?.allowPlainTextFallback ?? true
if (process.platform === 'darwin') {
return createFallbackStorage(macOsKeychainStorage, plainTextStorage)
return allowPlainTextFallback
? createFallbackStorage(macOsKeychainStorage, plainTextStorage)
: macOsKeychainStorage
}
if (process.platform === 'linux') {
return createFallbackStorage(linuxSecretStorage, plainTextStorage)
return allowPlainTextFallback
? createFallbackStorage(linuxSecretStorage, plainTextStorage)
: linuxSecretStorage
}
if (process.platform === 'win32') {
return createFallbackStorage(windowsCredentialStorage, plainTextStorage)
return allowPlainTextFallback
? createFallbackStorage(windowsCredentialStorage, plainTextStorage)
: windowsCredentialStorage
}
return plainTextStorage
return allowPlainTextFallback ? plainTextStorage : unavailableSecureStorage
}

View File

@@ -64,8 +64,10 @@ describe("Secure Storage Platform Implementations", () => {
windowsCredentialStorage.update(testData);
const script = mockExecaSync.mock.calls[0][1][1];
const options = mockExecaSync.mock.calls[0][2];
expect(script).toContain(expectedName);
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
expect(script).toContain("ProtectedData");
expect(options.input).toContain("secret-token");
});
});
@@ -85,32 +87,54 @@ describe("Secure Storage Platform Implementations", () => {
windowsCredentialStorage.update(dataWithDollar);
const script = mockExecaSync.mock.calls[0][1][1];
// Should use single quotes for the payload
expect(script).toMatch(/'\{.*\}'/);
// Should escape ' by doubling it
expect(script).not.toContain("'token-with-$env:USERNAME'");
// But since it's JSON, the value will be "token-with-$env:USERNAME" inside the single-quoted string
// The JSON itself shouldn't have single quotes unless the data has them.
const options = mockExecaSync.mock.calls[0][2];
expect(script).toContain("[Console]::In.ReadToEnd()");
expect(options.input).toContain("token-with-$env:USERNAME");
const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } };
windowsCredentialStorage.update(dataWithQuote);
const script2 = mockExecaSync.mock.calls[1][1][1];
expect(script2).toContain("token''quote");
const options2 = mockExecaSync.mock.calls[1][2];
expect(options2.input).toContain("token'quote");
});
test("delete() includes assembly load", () => {
windowsCredentialStorage.delete();
const script = mockExecaSync.mock.calls[0][1][1];
const script = mockExecaSync.mock.calls[1][1][1];
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
});
test("escapes double quotes in username", () => {
process.env.USER = 'user"name';
windowsCredentialStorage.read();
const script = mockExecaSync.mock.calls[0][1][1];
const script = mockExecaSync.mock.calls[1][1][1];
expect(script).toContain('user`"name');
expect(script).not.toContain('user"name');
});
test("read() falls back to legacy PasswordVault when the DPAPI payload is invalid JSON", () => {
mockExecaSync
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }))
.mockImplementationOnce(() => ({
exitCode: 0,
stdout: JSON.stringify(testData),
}));
const result = windowsCredentialStorage.read();
expect(result).toEqual(testData);
expect(mockExecaSync).toHaveBeenCalledTimes(2);
});
test("read() fails closed when the legacy PasswordVault payload is invalid JSON", () => {
mockExecaSync
.mockImplementationOnce(() => ({ exitCode: 1, stdout: "" }))
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }));
const result = windowsCredentialStorage.read();
expect(result).toBeNull();
expect(mockExecaSync).toHaveBeenCalledTimes(2);
});
});
describe("Linux secret-tool Interaction", () => {

View File

@@ -1,4 +1,6 @@
import { execaSync } from 'execa'
import { join } from 'path'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import {
CREDENTIALS_SERVICE_SUFFIX,
@@ -8,90 +10,216 @@ import {
import type { SecureStorage, SecureStorageData } from './index.js'
/**
* Windows-specific secure storage implementation using the Windows Credential Locker.
* Accessed via PowerShell's [Windows.Security.Credentials.PasswordVault].
* Windows-specific secure storage implementation using DPAPI for new writes,
* with best-effort reads/deletes from the legacy PasswordVault path.
*/
export const windowsCredentialStorage: SecureStorage = {
name: 'credential-locker',
read(): SecureStorageData | null {
const resourceName = getSecureStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
).replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
// PowerShell script to retrieve password from vault
const script = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
function escapePowerShellSingleQuoted(value: string): string {
return value.replace(/'/g, "''")
}
function getLegacyResourceName(): string {
return getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX)
}
function getWindowsSecureStorageEntropy(): string {
return `${getLegacyResourceName()}:${getUsername()}`
}
function getWindowsSecureStorageFilePath(): string {
const resourceName = getLegacyResourceName().replace(/[^a-zA-Z0-9._-]/g, '_')
return join(getClaudeConfigHomeDir(), `${resourceName}.secure.dpapi`)
}
function runPowerShell(
script: string,
options?: { input?: string },
): ReturnType<typeof execaSync> | null {
try {
return execaSync('powershell.exe', ['-Command', script], {
input: options?.input,
reject: false,
})
} catch {
return null
}
}
function getFailureWarning(
result: ReturnType<typeof execaSync> | null,
fallback: string,
): string {
const stderr = result?.stderr?.trim()
if (stderr) {
return stderr
}
if (typeof result?.exitCode === 'number' && result.exitCode !== 0) {
return `${fallback} (exit code ${result.exitCode}).`
}
return fallback
}
function readLegacyPasswordVault(): SecureStorageData | null {
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
const script = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
try {
$vault = New-Object Windows.Security.Credentials.PasswordVault
$cred = $vault.Retrieve("${resourceName}", "${username}")
$cred.FillPassword()
[Console]::Out.Write($cred.Password)
} catch {
exit 1
}
`
const result = runPowerShell(script)
if (result?.exitCode === 0 && result.stdout) {
try {
return jsonParse(result.stdout)
} catch {
return null
}
}
return null
}
export const windowsCredentialStorage: SecureStorage = {
name: 'credential-locker-dpapi',
read(): SecureStorageData | null {
const filePath = escapePowerShellSingleQuoted(
getWindowsSecureStorageFilePath(),
)
const entropy = escapePowerShellSingleQuoted(
getWindowsSecureStorageEntropy(),
)
const script = `
try {
$cred = $vault.Retrieve("${resourceName}", "${username}")
$cred.FillPassword()
$cred.Password
Add-Type -AssemblyName System.Security
$path = '${filePath}'
if (!(Test-Path -LiteralPath $path)) {
exit 1
}
$protectedBase64 = [System.IO.File]::ReadAllText(
$path,
[System.Text.Encoding]::UTF8
).Trim()
if (-not $protectedBase64) {
exit 1
}
$protectedBytes = [Convert]::FromBase64String($protectedBase64)
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
$protectedBytes,
$entropyBytes,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes))
} catch {
exit 1
}
`
try {
const result = execaSync('powershell.exe', ['-Command', script], {
reject: false,
})
if (result.exitCode === 0 && result.stdout) {
const result = runPowerShell(script)
if (result?.exitCode === 0 && result.stdout) {
try {
return jsonParse(result.stdout)
} catch {
return readLegacyPasswordVault()
}
} catch {
// fall through
}
return null
return readLegacyPasswordVault()
},
async readAsync(): Promise<SecureStorageData | null> {
return this.read()
},
update(data: SecureStorageData): { success: boolean; warning?: string } {
const resourceName = getSecureStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
).replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
// Use single quotes for the payload and escape ' by doubling it ('').
// This prevents PowerShell from expanding $... inside the string.
const payload = jsonStringify(data).replace(/'/g, "''")
// PowerShell script to add/update credential in vault
const filePath = escapePowerShellSingleQuoted(
getWindowsSecureStorageFilePath(),
)
const entropy = escapePowerShellSingleQuoted(
getWindowsSecureStorageEntropy(),
)
const payload = jsonStringify(data)
const script = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$vault = New-Object Windows.Security.Credentials.PasswordVault
$cred = New-Object Windows.Security.Credentials.PasswordCredential("${resourceName}", "${username}", '${payload}')
$vault.Add($cred)
try {
Add-Type -AssemblyName System.Security
$path = '${filePath}'
$directory = [System.IO.Path]::GetDirectoryName($path)
if ($directory) {
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
}
$payload = [Console]::In.ReadToEnd()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect(
$bytes,
$entropyBytes,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
$protectedBase64 = [Convert]::ToBase64String($protectedBytes)
[System.IO.File]::WriteAllText(
$path,
$protectedBase64,
[System.Text.Encoding]::UTF8
)
} catch {
Write-Error $_.Exception.Message
exit 1
}
`
try {
const result = execaSync('powershell.exe', ['-Command', script], {
reject: false,
})
return { success: result.exitCode === 0 }
} catch {
return { success: false }
const result = runPowerShell(script, { input: payload })
if (result?.exitCode === 0) {
return { success: true }
}
return {
success: false,
warning: getFailureWarning(
result,
'Windows secure storage could not encrypt credentials with DPAPI',
),
}
},
delete(): boolean {
const resourceName = getSecureStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
).replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
// PowerShell script to remove credential from vault
const script = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$vault = New-Object Windows.Security.Credentials.PasswordVault
const filePath = escapePowerShellSingleQuoted(
getWindowsSecureStorageFilePath(),
)
const removeDpapiScript = `
try {
$path = '${filePath}'
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force
}
} catch {
exit 1
}
`
const removeDpapiResult = runPowerShell(removeDpapiScript)
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
const username = getUsername().replace(/"/g, '`"')
const removeLegacyScript = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
try {
$vault = New-Object Windows.Security.Credentials.PasswordVault
$cred = $vault.Retrieve("${resourceName}", "${username}")
$vault.Remove($cred)
} catch {
exit 0
}
`
try {
const result = execaSync('powershell.exe', ['-Command', script], {
reject: false,
})
return result.exitCode === 0
} catch {
return false
}
const removeLegacyResult = runPowerShell(removeLegacyScript)
void removeLegacyResult
return (removeDpapiResult?.exitCode ?? 1) === 0
},
}