Skip to content

Commit 7f363fc

Browse files
committed
feat: API Keys tab in dashboard + key generation/auth
New dashboard page 'API' for managing proxy access keys: - Generate API keys (SHA-256 hash of provider keys + random salt) - Keys prefixed with 'ocx_' (40 hex chars) - List/delete keys in dashboard UI - Usage example with curl Server auth enhanced: - hasValidApiAuth now checks config.apiKeys in addition to env token - Supports both x-opencodex-api-key header and Authorization: Bearer - Keys stored in config.json (apiKeys array) Endpoints: - GET /api/keys — list keys (prefix only) + endpoint URL - POST /api/keys — generate new key - DELETE /api/keys — revoke a key by id
1 parent e4e0612 commit 7f363fc

7 files changed

Lines changed: 224 additions & 8 deletions

File tree

gui/src/App.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@ import Subagents from "./pages/Subagents";
66
import Logs from "./pages/Logs";
77
import Usage from "./pages/Usage";
88
import CodexAuth from "./pages/CodexAuth";
9+
import ApiKeys from "./pages/ApiKeys";
910
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconKey, IconGithub, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower } from "./icons";
1011
import { useI18n, useT, LOCALES, type TKey } from "./i18n";
1112
import { installApiAuthFetch } from "./api";
1213

1314
installApiAuthFetch();
1415

15-
type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "usage" | "codex-auth";
16+
type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "usage" | "codex-auth" | "api";
1617
type Theme = "light" | "dark" | "system";
1718

18-
const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "subagents", "logs", "usage", "codex-auth"]);
19+
const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "subagents", "logs", "usage", "codex-auth", "api"]);
1920

2021
function readPageFromHash(): Page {
2122
const raw = location.hash.replace(/^#\/?/, "");
@@ -33,6 +34,7 @@ const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
3334
{ id: "logs", tkey: "nav.logs", Icon: IconList },
3435
{ id: "usage", tkey: "nav.usage", Icon: IconActivity },
3536
{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey },
37+
{ id: "api", tkey: "nav.api", Icon: IconGlobe },
3638
];
3739

3840
const THEME_ICON = { light: IconSun, dark: IconMoon, system: IconMonitor } as const;
@@ -147,6 +149,7 @@ export default function App() {
147149
{page === "logs" && <Logs apiBase={API_BASE} />}
148150
{page === "usage" && <Usage apiBase={API_BASE} />}
149151
{page === "codex-auth" && <CodexAuth apiBase={API_BASE} />}
152+
{page === "api" && <ApiKeys apiBase={API_BASE} />}
150153
</div>
151154
</main>
152155
</div>

gui/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export const en = {
236236
"modal.loggingIn": "Logging in…",
237237
"modal.loginTimeout": "Login timed out — try again.",
238238
"nav.codexAuth": "Codex Auth",
239+
"nav.api": "API",
239240
"codexAuth.mainAccount": "Main Account",
240241
"codexAuth.appLogin": "App login",
241242
"codexAuth.accountPool": "Account Pool",

gui/src/i18n/ko.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ export const ko: Record<TKey, string> = {
235235
"modal.waitingLogin": "브라우저 로그인 대기 중…",
236236
"modal.loggingIn": "로그인 중…",
237237
"modal.loginTimeout": "로그인 시간 초과 — 다시 시도하세요.",
238+
"nav.api": "API",
238239
"nav.codexAuth": "Codex 인증",
239240
"codexAuth.mainAccount": "메인 계정",
240241
"codexAuth.appLogin": "앱 로그인",

gui/src/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ export const zh: Record<TKey, string> = {
235235
"modal.waitingLogin": "等待浏览器登录…",
236236
"modal.loggingIn": "登录中…",
237237
"modal.loginTimeout": "登录超时 — 请重试。",
238+
"nav.api": "API",
238239
"nav.codexAuth": "Codex 认证",
239240
"codexAuth.mainAccount": "主账号",
240241
"codexAuth.appLogin": "应用登录",

gui/src/pages/ApiKeys.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useEffect, useState } from "react";
2+
import { IconKey, IconPlus, IconX, IconCheck } from "../icons";
3+
4+
interface ApiKeyEntry {
5+
id: string;
6+
name: string;
7+
prefix: string;
8+
createdAt: string;
9+
}
10+
11+
export default function ApiKeys({ apiBase }: { apiBase: string }) {
12+
const [keys, setKeys] = useState<ApiKeyEntry[]>([]);
13+
const [endpoint, setEndpoint] = useState("");
14+
const [newName, setNewName] = useState("");
15+
const [creating, setCreating] = useState(false);
16+
const [newKey, setNewKey] = useState<string | null>(null);
17+
const [copied, setCopied] = useState(false);
18+
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
19+
20+
const fetchKeys = async () => {
21+
try {
22+
const res = await fetch(`${apiBase}/api/keys`);
23+
if (res.ok) {
24+
const data = await res.json();
25+
setKeys(data.keys ?? []);
26+
setEndpoint(data.endpoint ?? "");
27+
}
28+
} catch { /* proxy down */ }
29+
};
30+
31+
useEffect(() => { fetchKeys(); }, []);
32+
33+
const handleCreate = async () => {
34+
setCreating(true);
35+
try {
36+
const res = await fetch(`${apiBase}/api/keys`, {
37+
method: "POST",
38+
headers: { "Content-Type": "application/json" },
39+
body: JSON.stringify({ name: newName || "default" }),
40+
});
41+
if (res.ok) {
42+
const data = await res.json();
43+
setNewKey(data.key);
44+
setNewName("");
45+
fetchKeys();
46+
}
47+
} finally {
48+
setCreating(false);
49+
}
50+
};
51+
52+
const handleDelete = async (id: string) => {
53+
await fetch(`${apiBase}/api/keys`, {
54+
method: "DELETE",
55+
headers: { "Content-Type": "application/json" },
56+
body: JSON.stringify({ id }),
57+
});
58+
setConfirmDelete(null);
59+
fetchKeys();
60+
};
61+
62+
const copyKey = () => {
63+
if (newKey) {
64+
navigator.clipboard.writeText(newKey);
65+
setCopied(true);
66+
setTimeout(() => setCopied(false), 2000);
67+
}
68+
};
69+
70+
return (
71+
<section className="page">
72+
<h2><IconKey /> API Access</h2>
73+
<p className="muted">
74+
Use generated API keys to access the opencodex proxy from external apps.
75+
Keys authenticate via <code>Authorization: Bearer ocx_...</code> or <code>x-opencodex-api-key</code> header.
76+
</p>
77+
78+
<div className="card" style={{ marginTop: "1rem" }}>
79+
<h3>Endpoint</h3>
80+
<code className="block">{endpoint || "http://127.0.0.1:10100/v1/responses"}</code>
81+
<p className="muted small">Compatible with OpenAI Responses API format.</p>
82+
</div>
83+
84+
{newKey && (
85+
<div className="card highlight" style={{ marginTop: "1rem" }}>
86+
<h3>New Key Created</h3>
87+
<p className="muted small">Copy this key now — it won't be shown again.</p>
88+
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
89+
<code className="block" style={{ flex: 1, wordBreak: "break-all" }}>{newKey}</code>
90+
<button className="btn btn-sm" onClick={copyKey}>
91+
{copied ? <><IconCheck /> Copied</> : "Copy"}
92+
</button>
93+
</div>
94+
<button className="btn btn-sm" style={{ marginTop: "0.5rem" }} onClick={() => setNewKey(null)}>
95+
Dismiss
96+
</button>
97+
</div>
98+
)}
99+
100+
<div className="card" style={{ marginTop: "1rem" }}>
101+
<h3>Generate Key</h3>
102+
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
103+
<input
104+
type="text"
105+
placeholder="Key name (optional)"
106+
value={newName}
107+
onChange={e => setNewName(e.target.value)}
108+
className="input"
109+
style={{ flex: 1 }}
110+
/>
111+
<button className="btn btn-primary" onClick={handleCreate} disabled={creating}>
112+
<IconPlus /> {creating ? "Creating..." : "Generate"}
113+
</button>
114+
</div>
115+
</div>
116+
117+
<div className="card" style={{ marginTop: "1rem" }}>
118+
<h3>Active Keys ({keys.length})</h3>
119+
{keys.length === 0 ? (
120+
<p className="muted">No API keys yet. Generate one above.</p>
121+
) : (
122+
<table className="table">
123+
<thead>
124+
<tr><th>Name</th><th>Key</th><th>Created</th><th></th></tr>
125+
</thead>
126+
<tbody>
127+
{keys.map(k => (
128+
<tr key={k.id}>
129+
<td>{k.name}</td>
130+
<td><code>{k.prefix}</code></td>
131+
<td>{new Date(k.createdAt).toLocaleDateString()}</td>
132+
<td>
133+
{confirmDelete === k.id ? (
134+
<span style={{ display: "flex", gap: "0.25rem" }}>
135+
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(k.id)}>Confirm</button>
136+
<button className="btn btn-sm" onClick={() => setConfirmDelete(null)}>Cancel</button>
137+
</span>
138+
) : (
139+
<button className="btn btn-sm" onClick={() => setConfirmDelete(k.id)}><IconX /></button>
140+
)}
141+
</td>
142+
</tr>
143+
))}
144+
</tbody>
145+
</table>
146+
)}
147+
</div>
148+
149+
<div className="card" style={{ marginTop: "1rem" }}>
150+
<h3>Usage Example</h3>
151+
<pre className="block">{`curl ${endpoint || "http://127.0.0.1:10100"}/v1/responses \\
152+
-H "Authorization: Bearer ocx_YOUR_KEY_HERE" \\
153+
-H "Content-Type: application/json" \\
154+
-d '{
155+
"model": "gpt-5.4",
156+
"input": "Hello, world!"
157+
}'`}</pre>
158+
</div>
159+
</section>
160+
);
161+
}

src/server.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,13 +1566,27 @@ export function assertServerAuthConfig(config: OcxConfig): void {
15661566

15671567
export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
15681568
if (!isApiAuthRequired(config)) return true;
1569+
const actual = req.headers.get("x-opencodex-api-key")?.trim()
1570+
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
1571+
if (!actual) return false;
1572+
// Check env-based token
15691573
const expected = configuredApiAuthToken(config);
1570-
const actual = req.headers.get("x-opencodex-api-key")?.trim();
1571-
if (!expected || !actual) return false;
1572-
const enc = new TextEncoder();
1573-
const expectedBytes = enc.encode(expected);
1574-
const actualBytes = enc.encode(actual);
1575-
return expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes);
1574+
if (expected) {
1575+
const enc = new TextEncoder();
1576+
const expectedBytes = enc.encode(expected);
1577+
const actualBytes = enc.encode(actual);
1578+
if (expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes)) return true;
1579+
}
1580+
// Check config-based API keys
1581+
if (config.apiKeys?.length) {
1582+
const enc = new TextEncoder();
1583+
const actualBytes = enc.encode(actual);
1584+
for (const k of config.apiKeys) {
1585+
const keyBytes = enc.encode(k.key);
1586+
if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
1587+
}
1588+
}
1589+
return false;
15761590
}
15771591

15781592
function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
@@ -2079,6 +2093,37 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
20792093
return jsonResponse({ success: true });
20802094
}
20812095

2096+
// ---------------------------------------------------------------------------
2097+
// API Keys management
2098+
// ---------------------------------------------------------------------------
2099+
if (url.pathname === "/api/keys" && req.method === "GET") {
2100+
const keys = config.apiKeys ?? [];
2101+
return jsonResponse({ keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })), endpoint: `http://${config.hostname ?? "127.0.0.1"}:${listenPort}/v1/responses` }, 200, req, config);
2102+
}
2103+
2104+
if (url.pathname === "/api/keys" && req.method === "POST") {
2105+
const body = await req.json() as { name?: string };
2106+
const name = (body.name ?? "").trim() || "default";
2107+
// Generate key from provider keys hash + random salt
2108+
const providerKeys = Object.values(config.providers).map(p => p.apiKey ?? "").filter(Boolean).join("|");
2109+
const salt = crypto.randomUUID();
2110+
const hashInput = `${providerKeys}|${salt}|${Date.now()}`;
2111+
const hashBuf = new Bun.CryptoHasher("sha256").update(hashInput).digest();
2112+
const key = "ocx_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
2113+
const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
2114+
config.apiKeys = [...(config.apiKeys ?? []), entry];
2115+
saveConfig(config);
2116+
return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
2117+
}
2118+
2119+
if (url.pathname === "/api/keys" && req.method === "DELETE") {
2120+
const body = await req.json() as { id?: string };
2121+
if (!body.id) return jsonResponse({ error: "id required" }, 400, req, config);
2122+
config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
2123+
saveConfig(config);
2124+
return jsonResponse({ success: true }, 200, req, config);
2125+
}
2126+
20822127
if (url.pathname === "/api/stop" && req.method === "POST") {
20832128
const { restoreNativeCodex } = await import("./codex-inject");
20842129
const { stopServiceIfInstalled } = await import("./service");

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ export interface OcxConfig {
251251
shutdownTimeoutMs?: number;
252252
/** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
253253
websockets?: boolean;
254+
/** Generated API keys for external access to the proxy's /v1/responses endpoint. */
255+
apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>;
254256
/** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
255257
codexAutoStart?: boolean;
256258
/**
@@ -278,6 +280,8 @@ export interface OcxConfig {
278280
upstreamFailoverThreshold?: number;
279281
/** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */
280282
tokenGuardian?: OcxTokenGuardianConfig;
283+
/** Additional origins allowed for CORS (e.g. ["https://clisu-oracle.tail19a2d7.ts.net"]). Loopback origins are always allowed. */
284+
corsAllowOrigins?: string[];
281285
}
282286

283287
/**

0 commit comments

Comments
 (0)