fix(codex-auth): harden profile review findings

This commit is contained in:
Tam Nhu Tran
2026-05-17 15:32:29 -04:00
parent 4e7d648967
commit a3fe2c63d8
15 changed files with 643 additions and 86 deletions
+13 -9
View File
@@ -20,6 +20,7 @@ import * as path from 'path';
import * as yaml from 'js-yaml';
import { createLogger } from '../services/logging';
import { decodeAccountIdentity } from './codex-account-identity';
import { hasStructurallyValidIdToken } from './decode-id-token';
import { getCodexAuthRegistryPath, getCodexInstancesDir } from './codex-profile-paths';
import type { CodexProfileData } from './types';
@@ -99,14 +100,16 @@ function buildProfileEntry(name: string): CodexAuthProfileEntry {
if (fs.existsSync(authJsonPath)) {
// decodeAccountIdentity never throws; returns {} on any error
const identity = decodeAccountIdentity(authJsonPath);
authValid = Object.keys(identity).length > 0 || _hasValidStructure(authJsonPath);
authValid = Object.keys(identity).length > 0 || _hasStructurallyValidIdToken(authJsonPath);
email = identity.email ?? null;
plan = identity.plan_type ?? null;
accountId = identity.account_id ?? null;
logger.debug(
'codex-auth.dashboard.decoded',
`Decoded auth for profile=${name} email=${email ?? '(none)'}`
);
logger.debug('codex-auth.dashboard.decoded', 'Decoded auth profile summary', {
profileName: name,
hasEmail: email !== null,
hasPlan: plan !== null,
hasAccountId: accountId !== null,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -129,15 +132,16 @@ function buildProfileEntry(name: string): CodexAuthProfileEntry {
}
/**
* Check whether auth.json has the expected structure (tokens.id_token present),
* even if decoding yielded no display fields (e.g. no email in JWT).
* Check whether auth.json has a parseable JWT payload, even if it has no
* display fields. A non-empty but malformed token is not valid auth.
* This sets authValid=true for valid-but-sparse tokens.
*/
function _hasValidStructure(authJsonPath: string): boolean {
function _hasStructurallyValidIdToken(authJsonPath: string): boolean {
try {
const raw = fs.readFileSync(authJsonPath, 'utf8');
const parsed = JSON.parse(raw) as { tokens?: { id_token?: string } };
return typeof parsed?.tokens?.id_token === 'string' && parsed.tokens.id_token.length > 0;
const idToken = parsed?.tokens?.id_token;
return typeof idToken === 'string' && hasStructurallyValidIdToken(idToken);
} catch {
return false;
}
+107 -41
View File
@@ -1,23 +1,41 @@
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import * as lockfile from 'proper-lockfile';
import { createLogger } from '../services/logging';
import { getCodexAuthRegistryPath } from './codex-profile-paths';
import { CODEX_PROFILE_SCHEMA_VERSION } from './types';
import type { CodexProfileData, CodexProfileMetadata } from './types';
const logger = createLogger('codex-auth:registry');
const REGISTRY_LOCK_STALE_MS = 10000;
const REGISTRY_LOCK_RETRIES = 40;
const REGISTRY_LOCK_RETRY_DELAY_MS = 50;
function emptyRegistry(): CodexProfileData {
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
}
function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
const end = Date.now() + ms;
while (Date.now() < end) {
// Fall back for runtimes without Atomics.wait.
}
}
}
function isLockContentionError(err: unknown): boolean {
return (err as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED';
}
/**
* Registry for codex auth profiles stored at ~/.ccs/codex-profiles.yaml.
*
* All writes are atomic (tmp file + POSIX rename). Concurrent writers are
* safe: last-writer-wins for the default pointer; profile entries never
* partially corrupt because rename(2) is atomic.
* Writes are guarded by a registry-directory lock around the read-modify-write
* cycle, then persisted atomically with tmp file + POSIX rename.
*
* Constructor accepts an optional registryPath for test isolation.
*/
@@ -79,6 +97,44 @@ export class CodexProfileRegistry {
}
}
private _withRegistryWriteLock<T>(callback: () => T): T {
const dir = path.dirname(this.registryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
let release: (() => void) | undefined;
let lastLockError: unknown;
for (let attempt = 0; attempt <= REGISTRY_LOCK_RETRIES; attempt++) {
try {
release = lockfile.lockSync(dir, { stale: REGISTRY_LOCK_STALE_MS }) as () => void;
break;
} catch (err) {
if (!isLockContentionError(err) || attempt === REGISTRY_LOCK_RETRIES) {
throw err;
}
lastLockError = err;
sleepSync(REGISTRY_LOCK_RETRY_DELAY_MS);
}
}
if (!release) {
const msg = lastLockError instanceof Error ? lastLockError.message : 'unknown lock error';
throw new Error(`Failed to acquire codex profile registry lock: ${msg}`);
}
try {
return callback();
} finally {
try {
release();
} catch {
// Best-effort release.
}
}
}
// Best-effort cleanup of orphan tmp files older than 1 hour (H3 mitigation).
private _cleanOrphanTmpFiles(): void {
const dir = path.dirname(this.registryPath);
@@ -106,18 +162,20 @@ export class CodexProfileRegistry {
// ── CRUD ────────────────────────────────────────────────────────────────
createProfile(name: string, meta: Partial<CodexProfileMetadata> = {}): void {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
}
data.profiles[name] = {
type: 'codex',
created: new Date().toISOString(),
last_used: null,
...meta,
} as CodexProfileMetadata;
this._write(data);
logger.stage('route', 'codex-auth.profile.created', 'Codex profile created', { name });
this._withRegistryWriteLock(() => {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
}
data.profiles[name] = {
type: 'codex',
created: new Date().toISOString(),
last_used: null,
...meta,
} as CodexProfileMetadata;
this._write(data);
logger.stage('route', 'codex-auth.profile.created', 'Codex profile created', { name });
});
}
getProfile(name: string): CodexProfileMetadata {
@@ -130,26 +188,30 @@ export class CodexProfileRegistry {
}
updateProfile(name: string, partial: Partial<CodexProfileMetadata>): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = { ...data.profiles[name], ...partial } as CodexProfileMetadata;
this._write(data);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = { ...data.profiles[name], ...partial } as CodexProfileMetadata;
this._write(data);
});
}
removeProfile(name: string): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
delete data.profiles[name];
if (data.default === name) {
const remaining = Object.keys(data.profiles);
data.default = remaining.length > 0 ? remaining[0] : null;
}
this._write(data);
logger.stage('cleanup', 'codex-auth.profile.deleted', 'Codex profile removed', { name });
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
delete data.profiles[name];
if (data.default === name) {
const remaining = Object.keys(data.profiles);
data.default = remaining.length > 0 ? remaining[0] : null;
}
this._write(data);
logger.stage('cleanup', 'codex-auth.profile.deleted', 'Codex profile removed', { name });
});
}
listProfiles(): string[] {
@@ -167,18 +229,22 @@ export class CodexProfileRegistry {
}
setDefault(name: string): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.default = name;
this._write(data);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.default = name;
this._write(data);
});
}
clearDefault(): void {
const data = this._read();
data.default = null;
this._write(data);
this._withRegistryWriteLock(() => {
const data = this._read();
data.default = null;
this._write(data);
});
}
touchProfile(name: string): void {
+6 -8
View File
@@ -155,14 +155,12 @@ async function _spawnLogin(
const authJsonPath = path.join(profileDir, 'auth.json');
if (code === 0 && fs.existsSync(authJsonPath)) {
const identity = decodeAccountIdentity(authJsonPath);
if (identity.email || identity.plan_type) {
ctx.registry.updateProfile(profileName, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
}
ctx.registry.updateProfile(profileName, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
const emailStr = identity.email ? ` as ${identity.email}` : '';
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
console.log(ok(`Logged in${emailStr}${planStr}`));
@@ -18,6 +18,7 @@ import { initUI } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink, decodeIdToken } from '../index';
import { hasStructurallyValidIdToken } from '../decode-id-token';
import { parseArgs, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
@@ -79,20 +80,15 @@ async function readAuthJsonSafe(authSrcPath: string): Promise<Record<string, unk
);
}
// Validate JWT shape: must have tokens.id_token as a 3-segment JWT
// Validate JWT shape: must have tokens.id_token with a parseable JWT payload.
const tokens = parsed['tokens'] as Record<string, unknown> | undefined;
if (tokens) {
const idToken = tokens['id_token'];
if (typeof idToken === 'string' && idToken.length > 0) {
const parts = idToken.split('.');
if (parts.length < 3) {
if (!hasStructurallyValidIdToken(idToken)) {
// Torn write mid-JWT — retry
throw new Error(`TORN_JWT: id_token has ${parts.length} segments (need 3)`);
throw new Error('TORN_JWT: id_token payload is not parseable');
}
// Attempt decode to verify shape is sane
const identity = decodeIdToken(idToken);
// If all fields empty but token has 3 segments, it might be valid (no email claim)
void identity;
}
}
+76 -3
View File
@@ -16,6 +16,7 @@ import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
import type { CodexProfileMetadata } from '../types';
export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
@@ -76,6 +77,7 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
// Load cached email for impact summary
const meta = registry.getProfile(profileName);
const originalDefault = registry.getDefault();
let emailStr = meta.email ?? null;
if (!emailStr && authExists) {
const identity = decodeAccountIdentity(authJsonPath);
@@ -108,9 +110,12 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
}
}
// Remove dir then registry entry
const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
try {
fs.rmSync(profileDir, { recursive: true, force: true });
fs.renameSync(profileDir, stagedDeleteDir);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EACCES') {
@@ -120,6 +125,74 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
throw err;
}
registry.removeProfile(profileName);
try {
registry.removeProfile(profileName);
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile registry update failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
try {
fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
} catch (err) {
const restoredDir = _restoreProfileDir(stagedDeleteDir, profileDir);
const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
const registryNote = restoredRegistry
? 'Profile registry entry was restored.'
: 'Profile registry entry could not be restored automatically.';
exitWithError(
`Profile data delete failed; profile data was preserved at ${preservedPath}. ${registryNote}\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
console.log(ok(`Profile removed: ${profileName}`));
}
function _restoreRegistryEntry(
registry: CodexCommandContext['registry'],
profileName: string,
meta: CodexProfileMetadata,
originalDefault: string | null
): boolean {
try {
if (registry.hasProfile(profileName)) {
registry.updateProfile(profileName, meta);
} else {
registry.createProfile(profileName, meta);
}
if (originalDefault === profileName) {
registry.setDefault(profileName);
}
return true;
} catch {
process.stderr.write(
`[!] Profile data delete failed and automatic registry restore failed for "${profileName}".\n`
);
return false;
}
}
function _restoreProfileDir(stagedDeleteDir: string, profileDir: string): boolean {
try {
if (fs.existsSync(stagedDeleteDir) && !fs.existsSync(profileDir)) {
fs.renameSync(stagedDeleteDir, profileDir);
return true;
}
return fs.existsSync(profileDir);
} catch {
process.stderr.write(
`[!] Registry update failed and automatic restore failed. Profile data remains at ${stagedDeleteDir}.\n`
);
return false;
}
}
+40 -7
View File
@@ -5,6 +5,7 @@ import type { CodexAccountIdentity } from './types';
// live under this key, NOT at top level.
const OPENAI_AUTH_CLAIM = 'https://api.openai.com/auth';
const OPENAI_PROFILE_CLAIM = 'https://api.openai.com/profile';
const BASE64URL_SEGMENT_RE = /^[A-Za-z0-9_-]+$/;
interface OpenAIAuthClaim {
chatgpt_plan_type?: string;
@@ -29,6 +30,14 @@ function base64urlDecode(str: string): string {
return Buffer.from(padded, 'base64').toString('utf8');
}
function isBase64UrlSegment(str: string): boolean {
return str.length > 0 && BASE64URL_SEGMENT_RE.test(str);
}
function decodeJsonSegment(str: string): unknown {
return JSON.parse(base64urlDecode(str));
}
/**
* Decode the payload of a JWT id_token without signature verification.
* Returns only the display-safe fields: email, plan_type, account_id.
@@ -39,13 +48,8 @@ function base64urlDecode(str: string): string {
*/
export function decodeIdToken(idToken: string): CodexAccountIdentity {
try {
const parts = idToken.split('.');
if (parts.length < 3) {
return {};
}
const rawPayload = base64urlDecode(parts[1]);
const payload = JSON.parse(rawPayload) as JwtPayload;
const payload = decodeJwtPayload(idToken);
if (!payload) return {};
const authClaim = payload[OPENAI_AUTH_CLAIM];
const profileClaim = payload[OPENAI_PROFILE_CLAIM];
@@ -69,3 +73,32 @@ export function decodeIdToken(idToken: string): CodexAccountIdentity {
return {};
}
}
export function hasStructurallyValidIdToken(idToken: string): boolean {
return decodeJwtPayload(idToken) !== null;
}
function decodeJwtPayload(idToken: string): JwtPayload | null {
try {
const parts = idToken.split('.');
if (parts.length !== 3) {
return null;
}
if (!parts.every((part) => isBase64UrlSegment(part))) {
return null;
}
const header = decodeJsonSegment(parts[0] ?? '');
if (!header || typeof header !== 'object' || Array.isArray(header)) {
return null;
}
const payload = decodeJsonSegment(parts[1] ?? '');
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return null;
}
return payload as JwtPayload;
} catch {
return null;
}
}
+9 -4
View File
@@ -40,6 +40,14 @@ function pwshDoubleQuote(value: string): string {
return '"' + value.replace(/"/g, '""').replace(/\$/g, '`$') + '"';
}
/**
* Quote a cmd.exe SET assignment. `set "KEY=value"` keeps command separators
* like &, |, <, and > inside the assignment instead of executing them.
*/
function cmdSetQuote(value: string): string {
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"');
}
/**
* Format a single env var export statement for the target shell.
* Used by use-command to emit eval-safe lines.
@@ -51,10 +59,7 @@ export function formatExport(shell: Shell, key: string, value: string): string {
case 'pwsh':
return `$env:${key} = ${pwshDoubleQuote(value)}`;
case 'cmd':
// cmd.exe: no quoting — values are used verbatim.
// NOTE: cmd.exe cannot eval output from a node process natively.
// Users should prefer PowerShell. See --help for details.
return `set ${key}=${value}`;
return `set "${key}=${cmdSetQuote(value)}"`;
default:
// bash / zsh
return `export ${key}=${posixSingleQuote(value)}`;