Skip to content

Commit d4b3c81

Browse files
committed
fix(gui): renew loopback dashboard sessions silently instead of prompting
Since the management/data credential split, an expired GUI session (5-minute TTL) or one invalidated by a proxy restart fell through to a window.prompt demanding the admin token — a credential the default loopback user never chose or even knows exists (it is auto-generated under ~/.opencodex). The dashboard fetch wrapper now re-bootstraps the session from a freshly served document before any prompt: the server already mints short-lived loopback sessions into the HTML meta tags on every page load, so renewal is silent and needs no user interaction. Origin and token-prefix validation is unchanged, and the prompt remains only as the operator fallback on non-loopback binds, where the server refuses to mint sessions at all. Security posture is unchanged: the server still decides when to issue sessions, tokens stay memory-only, and nothing new crosses the origin boundary.
1 parent c0ad57a commit d4b3c81

3 files changed

Lines changed: 174 additions & 11 deletions

File tree

docs-site/src/content/docs/guides/web-dashboard.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ ocx start
2121
bun run dev:gui
2222
```
2323

24+
## Sign-in
25+
26+
On the default loopback bind (`localhost` / `127.0.0.1`) the dashboard never asks for a token:
27+
the proxy mints short-lived GUI sessions into the served page and renews them silently when
28+
they expire or the proxy restarts. Only a dashboard bound to a non-loopback hostname requires
29+
the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated
30+
`~/.opencodex/admin-api-token` file).
31+
2432
## What you can do
2533

2634
| Area | What it does |

gui/src/api.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
let installed = false;
22
/** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */
3-
let promptInFlight: Promise<string | null> | null = null;
3+
let resolutionInFlight: Promise<string | null> | null = null;
4+
/** Unwrapped fetch captured at install time — used for session re-bootstrap so the
5+
* bootstrap document request itself never enters the 401 handling path. */
6+
let rawFetch: typeof fetch | null = null;
47
/**
58
* After the user cancels (or submits blank) once, suppress further prompts for this page
69
* lifetime so a staggered 401 fan-out does not reopen the dialog N times (#647 / Codex).
710
* A full reload clears module state and allows prompting again.
811
*/
912
let promptCancelled = false;
1013

14+
/** Document path re-fetched to mint a fresh loopback GUI session (server injects meta tags). */
15+
const SESSION_REBOOTSTRAP_PATH = "/";
16+
1117
function needsApiAuth(input: RequestInfo | URL): boolean {
1218
try {
1319
const raw = input instanceof Request ? input.url : String(input);
@@ -53,17 +59,58 @@ function loadInjectedSession(): void {
5359
const token = takeMetaContent("opencodex-session-token");
5460
const csrfToken = takeMetaContent("opencodex-session-csrf");
5561
const origin = takeMetaContent("opencodex-session-origin");
56-
if (!token?.startsWith("ocx_session_") || !csrfToken || origin !== window.location.origin) return;
57-
memoryToken = token;
58-
memoryCsrfToken = csrfToken;
59-
memorySessionOrigin = origin;
62+
storeSession(token, csrfToken, origin);
6063
}
6164

6265
/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */
6366
function clearTokenIfCurrent(expected: string | null): void {
6467
if (expected != null && readToken() === expected) clearToken();
6568
}
6669

70+
/** Validate and store a server-minted GUI session; rejects anything bound to another origin. */
71+
function storeSession(token: string | null, csrfToken: string | null, origin: string | null): boolean {
72+
if (!token?.startsWith("ocx_session_") || !csrfToken || origin !== window.location.origin) return false;
73+
memoryToken = token;
74+
memoryCsrfToken = csrfToken;
75+
memorySessionOrigin = origin;
76+
return true;
77+
}
78+
79+
/** Read one named meta tag out of a served HTML document (attribute order varies). */
80+
function metaContentFromHtml(html: string, name: string): string | null {
81+
for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
82+
const nameMatch = tag.match(/\bname="([^"]+)"/i);
83+
if (nameMatch?.[1] !== name) continue;
84+
const contentMatch = tag.match(/\bcontent="([^"]*)"/i);
85+
return contentMatch?.[1]?.trim() || null;
86+
}
87+
return null;
88+
}
89+
90+
/**
91+
* Silently renew the GUI session from a freshly served document. Loopback servers mint
92+
* short-lived sessions into the HTML on every page load, so an expired session (5-minute
93+
* TTL) or one invalidated by a proxy restart is replaced without ever asking the user for
94+
* a token. Returns null when the server refuses to mint sessions (non-loopback operator
95+
* dashboards), where the manual admin-token prompt remains the fallback.
96+
*/
97+
async function reBootstrapSessionToken(): Promise<string | null> {
98+
if (!rawFetch) return null;
99+
try {
100+
const response = await rawFetch(SESSION_REBOOTSTRAP_PATH, { cache: "no-store" });
101+
if (!response.ok) return null;
102+
const html = await response.text();
103+
const stored = storeSession(
104+
metaContentFromHtml(html, "opencodex-session-token"),
105+
metaContentFromHtml(html, "opencodex-session-csrf"),
106+
metaContentFromHtml(html, "opencodex-session-origin"),
107+
);
108+
return stored ? readToken() : null;
109+
} catch {
110+
return null;
111+
}
112+
}
113+
67114
function clearLegacySessionToken(): void {
68115
try {
69116
sessionStorage.removeItem(LEGACY_TOKEN_KEY);
@@ -93,25 +140,28 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke
93140
*/
94141
async function resolveTokenAfter401(failedToken: string | null): Promise<string | null> {
95142
if (promptCancelled) return null;
96-
if (promptInFlight) return promptInFlight;
143+
if (resolutionInFlight) return resolutionInFlight;
97144

98-
promptInFlight = (async () => {
145+
resolutionInFlight = (async () => {
99146
if (promptCancelled) return null;
100147
const current = readToken();
101148
if (current && current !== failedToken) return current;
102149

103-
const prompted = window.prompt("OpenCodex API token")?.trim() || null;
150+
const renewed = await reBootstrapSessionToken();
151+
if (renewed) return renewed;
152+
153+
const prompted = window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null;
104154
if (prompted) {
105155
storeToken(prompted);
106156
return prompted;
107157
}
108158
promptCancelled = true;
109159
return null;
110160
})().finally(() => {
111-
promptInFlight = null;
161+
resolutionInFlight = null;
112162
});
113163

114-
return promptInFlight;
164+
return resolutionInFlight;
115165
}
116166

117167
export function installApiAuthFetch(): void {
@@ -121,6 +171,7 @@ export function installApiAuthFetch(): void {
121171
clearLegacySessionToken();
122172
loadInjectedSession();
123173
const originalFetch = window.fetch.bind(window);
174+
rawFetch = originalFetch;
124175
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
125176
if (!needsApiAuth(input)) return originalFetch(input, init);
126177

@@ -156,6 +207,7 @@ export function resetApiAuthFetchForTests(): void {
156207
memoryToken = null;
157208
memoryCsrfToken = null;
158209
memorySessionOrigin = null;
159-
promptInFlight = null;
210+
resolutionInFlight = null;
211+
rawFetch = null;
160212
promptCancelled = false;
161213
}

gui/tests/api-auth-memory.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ test("concurrent 401s share one token prompt and all retry with the stored token
122122
const release401: Array<() => void> = [];
123123
const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
124124
const headers = new Headers(init?.headers);
125+
// Session re-bootstrap probe: this fixture never mints sessions, so fail it fast
126+
// instead of letting it join the release queue below.
127+
if (new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname === "/") {
128+
return new Response("unauthorized", { status: 401 });
129+
}
125130
if (headers.get("X-OpenCodex-API-Key") === "shared-token") {
126131
return new Response("{}", { status: 200 });
127132
}
@@ -240,6 +245,9 @@ test("canceling the token prompt once does not reopen it for the rest of the 401
240245
const release401: Array<() => void> = [];
241246
const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
242247
const headers = new Headers(init?.headers);
248+
if (new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname === "/") {
249+
return new Response("unauthorized", { status: 401 });
250+
}
243251
if (headers.get("X-OpenCodex-API-Key")) {
244252
return new Response("{}", { status: 200 });
245253
}
@@ -310,3 +318,98 @@ test("data-plane requests never receive the management token or prompt", async (
310318
expect(seenHeaders).toEqual([null]);
311319
expect(promptCalls).toBe(beforeCrossPrompts);
312320
});
321+
322+
function injectSessionMeta(token: string, csrf: string, origin: string): void {
323+
for (const [name, content] of [
324+
["opencodex-session-token", token],
325+
["opencodex-session-csrf", csrf],
326+
["opencodex-session-origin", origin],
327+
] as const) {
328+
const meta = document.createElement("meta");
329+
meta.setAttribute("name", name);
330+
meta.setAttribute("content", content);
331+
document.head.appendChild(meta);
332+
}
333+
}
334+
335+
function sessionDocumentHtml(token: string, csrf: string, origin: string): string {
336+
return [
337+
"<!doctype html><html><head>",
338+
`<meta name="opencodex-session-token" content="${token}">`,
339+
`<meta name="opencodex-session-csrf" content="${csrf}">`,
340+
`<meta name="opencodex-session-origin" content="${origin}">`,
341+
"</head><body></body></html>",
342+
].join("");
343+
}
344+
345+
test("expired session silently re-bootstraps from the served document without prompting", async () => {
346+
// Regression for the post-security-hardening UX bug: loopback sessions expire after the
347+
// 5-minute TTL (or die on proxy restart), and the dashboard used to demand an admin token
348+
// the user never chose. The fetch wrapper must renew the session from a freshly served
349+
// document instead — token entry is not part of the default loopback experience.
350+
injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost");
351+
352+
let promptCalls = 0;
353+
let bootstrapFetches = 0;
354+
const seenApiKeys: Array<string | null> = [];
355+
const seenGuiOrigins: Array<string | null> = [];
356+
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
357+
const raw = input instanceof Request ? input.url : String(input);
358+
const url = new URL(raw, "http://localhost/");
359+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
360+
if (url.pathname === "/") {
361+
bootstrapFetches += 1;
362+
return new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), {
363+
status: 200,
364+
headers: { "Content-Type": "text/html" },
365+
});
366+
}
367+
seenApiKeys.push(headers.get("X-OpenCodex-API-Key"));
368+
seenGuiOrigins.push(headers.get("X-OpenCodex-GUI-Origin"));
369+
if (headers.get("X-OpenCodex-API-Key") === "ocx_session_fresh"
370+
&& headers.get("X-OpenCodex-GUI-Origin") === "http://localhost") {
371+
return new Response("{}", { status: 200 });
372+
}
373+
return new Response("unauthorized", { status: 401 });
374+
}) as typeof fetch;
375+
window.prompt = () => {
376+
promptCalls += 1;
377+
return null;
378+
};
379+
await installMockAuthFetch(mockFetch);
380+
381+
const res = await fetch("/api/config");
382+
expect(res.status).toBe(200);
383+
expect(promptCalls).toBe(0);
384+
expect(bootstrapFetches).toBe(1);
385+
expect(seenApiKeys).toEqual(["ocx_session_stale", "ocx_session_fresh"]);
386+
expect(seenGuiOrigins).toEqual(["http://localhost", "http://localhost"]);
387+
});
388+
389+
test("a session minted for another origin is rejected and the prompt fallback stays", async () => {
390+
// Non-loopback dashboards never get server-minted sessions; a re-bootstrap document whose
391+
// origin does not match must not be trusted, and the operator-only prompt remains.
392+
let promptCalls = 0;
393+
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
394+
const raw = input instanceof Request ? input.url : String(input);
395+
const url = new URL(raw, "http://localhost/");
396+
const headers = new Headers(init?.headers);
397+
if (url.pathname === "/") {
398+
return new Response(sessionDocumentHtml("ocx_session_foreign", "foreign-csrf", "http://192.0.2.10:10100"), {
399+
status: 200,
400+
headers: { "Content-Type": "text/html" },
401+
});
402+
}
403+
if (headers.get("X-OpenCodex-API-Key") === "manual-admin-token") return new Response("{}", { status: 200 });
404+
return new Response("unauthorized", { status: 401 });
405+
}) as typeof fetch;
406+
window.prompt = () => {
407+
promptCalls += 1;
408+
return "manual-admin-token";
409+
};
410+
await installMockAuthFetch(mockFetch);
411+
412+
const res = await fetch("/api/config");
413+
expect(res.status).toBe(200);
414+
expect(promptCalls).toBe(1);
415+
});

0 commit comments

Comments
 (0)