mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +00:00
feat(codex-auth): add dashboard Auth Profiles tab + GET /api/codex/profiles
Adds a read-only dashboard surface for the codex-auth profile registry. Power users can see which profile is active and its decoded email/plan without leaving the browser; mutations stay CLI-only. - codex-auth-dashboard-service: builds the response shape (active + default + profiles[]), reads each profile's auth.json, decodes id_token via Phase 1 decoder (nested URI claims per C1), 5s in-memory single-key cache plus exported invalidateCodexAuthProfilesCache() hook for in-process Phase 2 callers (D7); strict field whitelist — id_token / access_token / refresh_token NEVER appear in response body or logs - GET /api/codex/profiles registered INSIDE the requireLocalAccessWhenAuthDisabled middleware (H6) — emails are PII and must not leak when dashboard is exposed remotely; integration test asserts 403 from non-localhost origin - NEW Auth Profiles tab (D5) in ui/src/pages/codex.tsx — distinct from the existing codex-profiles-card (which edits config.toml [profiles], a different concept); active profile + email + plan tier highlighted, table of all profiles below, disabled Switch/Remove buttons redirect to terminal commands - accountId returned by API for power users (curl) but hidden from the default UI (D6) - 11 service unit tests + 4 endpoint integration tests, all green
This commit is contained in:
1 parent
8c604a040f
commit
e99c4612a8
7 files changed
+1173
-1
No files matched your search
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Dashboard service for codex-auth profile summary.
|
||||
*
|
||||
* Reads the profile registry, decodes each profile's auth.json JWT,
|
||||
* resolves the active profile via env precedence, and returns the
|
||||
* API response shape for GET /api/codex/profiles.
|
||||
*
|
||||
* Security: tokens NEVER appear in the returned object. Only display-safe
|
||||
* fields (email, plan, accountId) are extracted from JWT. auth.json is
|
||||
* read/decoded and then discarded.
|
||||
*
|
||||
* Cache: 5-second in-memory single-key cache reduces fs reads during
|
||||
* dashboard polling. Out-of-process callers rely on the TTL. In-process
|
||||
* callers (Phase 2 CLI commands running in dev-server context) can call
|
||||
* invalidateCodexAuthProfilesCache() to force an immediate re-read.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { createLogger } from '../services/logging';
|
||||
import { decodeAccountIdentity } from './codex-account-identity';
|
||||
import { getCodexAuthRegistryPath, getCodexInstancesDir } from './codex-profile-paths';
|
||||
import type { CodexProfileData } from './types';
|
||||
|
||||
const logger = createLogger('codex-auth:dashboard');
|
||||
|
||||
// ── Response types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface CodexAuthProfileEntry {
|
||||
name: string;
|
||||
codexHome: string;
|
||||
email: string | null;
|
||||
plan: string | null;
|
||||
accountId: string | null;
|
||||
lastUsed: string | null;
|
||||
authValid: boolean;
|
||||
}
|
||||
|
||||
export interface CodexAuthActiveProfile {
|
||||
name: string | null;
|
||||
source: 'default' | 'env' | 'explicit-codex-home';
|
||||
codexHome: string;
|
||||
}
|
||||
|
||||
export interface CodexAuthProfilesSummary {
|
||||
active: CodexAuthActiveProfile | null;
|
||||
default: string | null;
|
||||
profiles: CodexAuthProfileEntry[];
|
||||
}
|
||||
|
||||
// ── Cache ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let cache: { value: CodexAuthProfilesSummary; expiresAt: number } | null = null;
|
||||
const TTL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Invalidate the in-process cache so the next call re-reads from disk.
|
||||
* Useful for Phase 2 CLI commands running in the same process as the dashboard.
|
||||
* Out-of-process invocations rely on the 5s TTL.
|
||||
*/
|
||||
export function invalidateCodexAuthProfilesCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
// ── Registry helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function readRegistry(): CodexProfileData {
|
||||
const registryPath = getCodexAuthRegistryPath();
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return { version: '1.0', default: null, profiles: {} };
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(registryPath, 'utf8');
|
||||
const parsed = yaml.load(raw) as CodexProfileData | null;
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.profiles) {
|
||||
return { version: '1.0', default: null, profiles: {} };
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn('codex-auth.dashboard.registry-read-failed', `Registry read failed: ${msg}`);
|
||||
return { version: '1.0', default: null, profiles: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Profile entry builder ───────────────────────────────────────────────────
|
||||
|
||||
function buildProfileEntry(name: string): CodexAuthProfileEntry {
|
||||
const codexHome = path.join(getCodexInstancesDir(), name);
|
||||
const authJsonPath = path.join(codexHome, 'auth.json');
|
||||
|
||||
let authValid = false;
|
||||
let email: string | null = null;
|
||||
let plan: string | null = null;
|
||||
let accountId: string | null = null;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(authJsonPath)) {
|
||||
// decodeAccountIdentity never throws; returns {} on any error
|
||||
const identity = decodeAccountIdentity(authJsonPath);
|
||||
authValid = Object.keys(identity).length > 0 || _hasValidStructure(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)'}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(
|
||||
'codex-auth.dashboard.decode-error',
|
||||
`Failed to decode auth for profile=${name}: ${msg}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
codexHome,
|
||||
email,
|
||||
plan,
|
||||
accountId,
|
||||
// lastUsed is set below by caller from registry metadata
|
||||
lastUsed: null,
|
||||
authValid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* This sets authValid=true for valid-but-sparse tokens.
|
||||
*/
|
||||
function _hasValidStructure(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;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active resolution ───────────────────────────────────────────────────────
|
||||
|
||||
function resolveActive(registry: CodexProfileData): CodexAuthActiveProfile | null {
|
||||
const instancesDir = getCodexInstancesDir();
|
||||
|
||||
// Precedence 1: explicit $CODEX_HOME set by env (ccsxp, manual)
|
||||
const codexHome = (process.env.CODEX_HOME ?? '').trim();
|
||||
if (codexHome) {
|
||||
// Attempt reverse-map: does any registered profile's codexHome match?
|
||||
const matchedName =
|
||||
Object.keys(registry.profiles).find((name) => path.join(instancesDir, name) === codexHome) ??
|
||||
null;
|
||||
return {
|
||||
name: matchedName,
|
||||
source: 'explicit-codex-home',
|
||||
codexHome,
|
||||
};
|
||||
}
|
||||
|
||||
// Precedence 2: $CCS_CODEX_PROFILE env var
|
||||
const profileEnv = (process.env.CCS_CODEX_PROFILE ?? '').trim();
|
||||
if (profileEnv) {
|
||||
return {
|
||||
name: profileEnv,
|
||||
source: 'env',
|
||||
codexHome: path.join(instancesDir, profileEnv),
|
||||
};
|
||||
}
|
||||
|
||||
// Precedence 3: registry default
|
||||
const defaultProfile = registry.default;
|
||||
if (defaultProfile) {
|
||||
return {
|
||||
name: defaultProfile,
|
||||
source: 'default',
|
||||
codexHome: path.join(instancesDir, defaultProfile),
|
||||
};
|
||||
}
|
||||
|
||||
// Precedence 4: no active profile (legacy ~/.codex mode)
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Core builder ────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildSummary(): Promise<CodexAuthProfilesSummary> {
|
||||
const registry = readRegistry();
|
||||
const active = resolveActive(registry);
|
||||
|
||||
const profiles: CodexAuthProfileEntry[] = Object.entries(registry.profiles).map(
|
||||
([name, meta]) => {
|
||||
const entry = buildProfileEntry(name);
|
||||
entry.lastUsed = meta.last_used ?? null;
|
||||
return entry;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
active,
|
||||
default: registry.default,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the codex-auth profiles summary, using a 5s in-memory cache.
|
||||
* Tokens are never included in the returned object.
|
||||
*/
|
||||
export async function getCodexAuthProfilesSummary(): Promise<CodexAuthProfilesSummary> {
|
||||
const now = Date.now();
|
||||
if (cache && cache.expiresAt > now) {
|
||||
return cache.value;
|
||||
}
|
||||
const value = await buildSummary();
|
||||
cache = { value, expiresAt: now + TTL_MS };
|
||||
return value;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
patchCodexConfig,
|
||||
saveCodexRawConfig,
|
||||
} from '../services/codex-dashboard-service';
|
||||
import { getCodexAuthProfilesSummary } from '../../codex-auth/codex-auth-dashboard-service';
|
||||
|
||||
const router = Router();
|
||||
const CODEX_CONFIG_ACCESS_ERROR =
|
||||
@@ -28,6 +29,21 @@ router.get('/diagnostics', async (_req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
});
|
||||
|
||||
// H6: email is PII — require localhost access when dashboard auth is disabled.
|
||||
const CODEX_PROFILES_ACCESS_ERROR =
|
||||
'Codex auth profiles endpoint requires localhost access when dashboard auth is disabled.';
|
||||
|
||||
router.get('/profiles', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireLocalAccessWhenAuthDisabled(req, res, CODEX_PROFILES_ACCESS_ERROR)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.json(await getCodexAuthProfilesSummary());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/config/raw', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
res.json(await getCodexRawConfig());
|
||||
|
||||
Reference in new issue
Block a user