From 626447bd5c87f3a9dff99cff5231f6c35b984338 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 3 Jul 2026 17:06:09 -0700 Subject: [PATCH] fix(web): preserve manual auth across hard reloads Fixes #2590 by keeping manually entered single-user auth available in sessionStorage after tldwConfig is scrubbed. Count runtime auth material in the WebUI shell gate and add regression coverage for the second hard reload path. --- .../__tests__/app/app-layout.test.tsx | 31 ++++++ .../extension/runtime-bootstrap.test.ts | 104 ++++++++++++++++++ .../extension/shims/runtime-bootstrap.ts | 70 ++++++++++-- apps/tldw-frontend/pages/_app.tsx | 11 +- ...sistence-after-runtime-credential-scrub.md | 49 +++++++++ 5 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 backlog/tasks/task-12127 - Fix-WebUI-auth-persistence-after-runtime-credential-scrub.md diff --git a/apps/tldw-frontend/__tests__/app/app-layout.test.tsx b/apps/tldw-frontend/__tests__/app/app-layout.test.tsx index 818e33ebe7..e3a96e2086 100644 --- a/apps/tldw-frontend/__tests__/app/app-layout.test.tsx +++ b/apps/tldw-frontend/__tests__/app/app-layout.test.tsx @@ -35,6 +35,16 @@ vi.mock("@web/extension/shims/runtime-bootstrap", () => ({ } })) +let mockRuntimeApiKey: string | null = null + +vi.mock("@web/lib/authStorage", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getRuntimeApiKey: () => mockRuntimeApiKey + } +}) + import App from "@web/pages/_app" const mockRouter = { @@ -149,6 +159,7 @@ beforeEach(() => { delete process.env.NEXT_PUBLIC_X_API_KEY delete process.env.NEXT_PUBLIC_API_BEARER delete process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE + mockRuntimeApiKey = null resetRuntimeBootstrap() }) @@ -387,6 +398,26 @@ describe("App layout routing", () => { expect(layout).toHaveAttribute("data-hide-sidebar", "false") }) + it("counts runtime-override credentials as authenticated for shell chrome", async () => { + currentConfig = { + serverUrl: "http://127.0.0.1:8000", + authMode: "single-user", + apiKey: "" + } + mockRuntimeApiKey = "runtime-override-key" + + renderApp("/media") + const layout = await screen.findByTestId("option-layout") + + await waitFor(() => { + expect(mockGetConfig).toHaveBeenCalled() + }) + await waitFor(() => { + expect(layout).toHaveAttribute("data-hide-header", "false") + }) + expect(layout).toHaveAttribute("data-hide-sidebar", "false") + }) + it("keeps header and sidebar hidden when multi-user token validation fails", async () => { currentConfig = { serverUrl: "http://127.0.0.1:8000", diff --git a/apps/tldw-frontend/__tests__/extension/runtime-bootstrap.test.ts b/apps/tldw-frontend/__tests__/extension/runtime-bootstrap.test.ts index c2bfd63d36..612dfc1075 100644 --- a/apps/tldw-frontend/__tests__/extension/runtime-bootstrap.test.ts +++ b/apps/tldw-frontend/__tests__/extension/runtime-bootstrap.test.ts @@ -14,6 +14,8 @@ const originalApiUrl = process.env.NEXT_PUBLIC_API_URL const originalDeploymentMode = process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE const originalXApiKey = process.env.NEXT_PUBLIC_X_API_KEY const originalWindowLocation = window.location +const RUNTIME_SESSION_SINGLE_USER_API_KEY = + "tldwRuntimeSessionSingleUserApiKey" const setWindowLocation = (href: string) => { Object.defineProperty(window, "location", { @@ -112,6 +114,7 @@ describe("runtime-bootstrap chrome shim", () => { }) vi.unstubAllGlobals() localStorage.clear() + sessionStorage.clear() }) it("creates browser/chrome shims when globals are absent", async () => { @@ -315,6 +318,107 @@ describe("runtime-bootstrap chrome shim", () => { }) }) + it("keeps a manual single-user key available after a second hard reload in the same browser session", async () => { + process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = "advanced" + process.env.NEXT_PUBLIC_API_URL = "http://127.0.0.1:8000" + delete process.env.NEXT_PUBLIC_X_API_KEY + localStorage.setItem( + "tldwConfig", + JSON.stringify({ + authMode: "single-user", + apiKey: "manual-user-key", + serverUrl: "http://127.0.0.1:8000" + }) + ) + + await importAndAwaitBootstrap() + let runtimeAuth = await import("@/services/tldw/runtime-auth-override") + + expect(runtimeAuth.getRuntimeSingleUserApiKeyOverride()).toBe( + "manual-user-key" + ) + expect(localStorage.getItem("tldwConfig")).not.toContain("manual-user-key") + + vi.resetModules() + await importAndAwaitBootstrap() + runtimeAuth = await import("@/services/tldw/runtime-auth-override") + + expect(runtimeAuth.getRuntimeSingleUserApiKeyOverride()).toBe( + "manual-user-key" + ) + expect(localStorage.getItem("tldwConfig")).not.toContain("manual-user-key") + }) + + it("rehydrates the session key when stored single-user config has a blank apiKey", async () => { + process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = "advanced" + process.env.NEXT_PUBLIC_API_URL = "http://127.0.0.1:8000" + delete process.env.NEXT_PUBLIC_X_API_KEY + localStorage.setItem( + "tldwConfig", + JSON.stringify({ + authMode: "single-user", + apiKey: "manual-user-key", + serverUrl: "http://127.0.0.1:8000" + }) + ) + + await importAndAwaitBootstrap() + expect(localStorage.getItem("tldwConfig")).not.toContain("manual-user-key") + + localStorage.setItem( + "tldwConfig", + JSON.stringify({ + authMode: "single-user", + apiKey: "", + serverUrl: "http://127.0.0.1:8000" + }) + ) + + vi.resetModules() + await importAndAwaitBootstrap() + const runtimeAuth = await import("@/services/tldw/runtime-auth-override") + + expect(runtimeAuth.getRuntimeSingleUserApiKeyOverride()).toBe( + "manual-user-key" + ) + expect(localStorage.getItem("tldwConfig")).not.toContain("manual-user-key") + }) + + it("clears the session key when stored config switches away from single-user auth", async () => { + process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = "advanced" + process.env.NEXT_PUBLIC_API_URL = "http://127.0.0.1:8000" + delete process.env.NEXT_PUBLIC_X_API_KEY + localStorage.setItem( + "tldwConfig", + JSON.stringify({ + authMode: "single-user", + apiKey: "manual-user-key", + serverUrl: "http://127.0.0.1:8000" + }) + ) + + await importAndAwaitBootstrap() + expect(sessionStorage.getItem(RUNTIME_SESSION_SINGLE_USER_API_KEY)).toBe( + "manual-user-key" + ) + + localStorage.setItem( + "tldwConfig", + JSON.stringify({ + authMode: "multi-user", + accessToken: "user-token", + serverUrl: "http://127.0.0.1:8000" + }) + ) + + vi.resetModules() + await importAndAwaitBootstrap() + const runtimeAuth = await import("@/services/tldw/runtime-auth-override") + + expect(sessionStorage.getItem(RUNTIME_SESSION_SINGLE_USER_API_KEY)).toBeNull() + expect(runtimeAuth.getRuntimeSingleUserApiKeyOverride()).toBeNull() + }) + it("repairs a stale env LAN host to the current browser host during bootstrap", async () => { process.env.NEXT_PUBLIC_API_URL = "http://192.168.5.184:8000" diff --git a/apps/tldw-frontend/extension/shims/runtime-bootstrap.ts b/apps/tldw-frontend/extension/shims/runtime-bootstrap.ts index 7b5f2c79e3..b70d35b8d8 100644 --- a/apps/tldw-frontend/extension/shims/runtime-bootstrap.ts +++ b/apps/tldw-frontend/extension/shims/runtime-bootstrap.ts @@ -209,6 +209,7 @@ const RUNTIME_CONFIG_ENDPOINT = "/api/_tldw-webui/runtime-config" const RUNTIME_AUTH_METADATA_KEY = "tldwRuntimeAuthMetadata" const RUNTIME_AUTH_METADATA_VERSION = 1 const RUNTIME_ENV_AUTH_OPT_OUT_KEY = "tldwRuntimeEnvAuthOptOut" +const RUNTIME_SESSION_SINGLE_USER_API_KEY = "tldwRuntimeSessionSingleUserApiKey" const PLACEHOLDER_API_KEYS = new Set([ "change-me", "changeme", @@ -334,6 +335,58 @@ const isPlaceholderApiKey = (value: string): boolean => { return PLACEHOLDER_API_KEYS.has(normalized) } +const readRuntimeSessionApiKey = (): string | null => { + if (typeof window === "undefined") return null + try { + const key = normalizeApiKey( + window.sessionStorage.getItem(RUNTIME_SESSION_SINGLE_USER_API_KEY) + ) + return key && !isPlaceholderApiKey(key) ? key : null + } catch (error) { + console.warn("[runtime-bootstrap] Failed to read session auth key", error) + return null + } +} + +const writeRuntimeSessionApiKey = (value?: string | null): void => { + if (typeof window === "undefined") return + const key = normalizeApiKey(value) + try { + if (key && !isPlaceholderApiKey(key)) { + // codeql[js/clear-text-storage-of-sensitive-data]: sessionStorage keeps a user-entered single-user key only for the current browser session after localStorage tldwConfig is scrubbed. + window.sessionStorage.setItem(RUNTIME_SESSION_SINGLE_USER_API_KEY, key) + } else { + window.sessionStorage.removeItem(RUNTIME_SESSION_SINGLE_USER_API_KEY) + } + } catch (error) { + // Best-effort only; runtime auth still works for the current load. + console.warn("[runtime-bootstrap] Failed to write session auth key", error) + } +} + +const deriveStoredSingleUserAuth = ( + existing: TldwConfig | null +): { authMode: string; manualKey: string | null } => { + const authMode = existing?.authMode || "single-user" + const rawSingleUserKey = + authMode === "single-user" && typeof existing?.apiKey === "string" + ? normalizeApiKey(existing.apiKey) + : null + const configKey = + rawSingleUserKey && !isPlaceholderApiKey(rawSingleUserKey) + ? rawSingleUserKey + : null + const sessionKey = + existing && authMode === "single-user" && !configKey + ? readRuntimeSessionApiKey() + : null + + return { + authMode, + manualKey: configKey || sessionKey + } +} + const shouldRecordRuntimeMetadata = async ({ existingKey, metadata, @@ -494,6 +547,7 @@ const seedTldwConfigFromEnv = async (): Promise => { if (envAuthOptedOut) { setRuntimeApiKey(null) setRuntimeSingleUserApiKeyOverride(null) + writeRuntimeSessionApiKey(null) } else if (apiKey && !getRuntimeSingleUserApiKeyOverride()) { setRuntimeApiKey(apiKey) setRuntimeSingleUserApiKeyOverride(apiKey) @@ -513,20 +567,20 @@ const seedTldwConfigFromEnv = async (): Promise => { const existing = (await storage.get("tldwConfig").catch(() => null)) || null const storedServerUrl = (await storage.get("tldwServerUrl").catch(() => null)) || null - const existingSingleUserKey = - existing?.authMode === "single-user" && typeof existing.apiKey === "string" - ? normalizeApiKey(existing.apiKey) - : null + const { authMode: existingAuthMode, manualKey: manualSingleUserKey } = + deriveStoredSingleUserAuth(existing) if ( !envAuthOptedOut && !apiKey && !apiBearer && - existingSingleUserKey && - !isPlaceholderApiKey(existingSingleUserKey) && + manualSingleUserKey && !getRuntimeSingleUserApiKeyOverride() ) { - setRuntimeApiKey(existingSingleUserKey) - setRuntimeSingleUserApiKeyOverride(existingSingleUserKey) + setRuntimeApiKey(manualSingleUserKey) + setRuntimeSingleUserApiKeyOverride(manualSingleUserKey) + writeRuntimeSessionApiKey(manualSingleUserKey) + } else if (existing && existingAuthMode !== "single-user") { + writeRuntimeSessionApiKey(null) } const quickstartWebUiServerUrl = getQuickstartWebUiServerUrl() const effectiveExplicitWebHost = diff --git a/apps/tldw-frontend/pages/_app.tsx b/apps/tldw-frontend/pages/_app.tsx index 11d0e31147..82a7fdf475 100644 --- a/apps/tldw-frontend/pages/_app.tsx +++ b/apps/tldw-frontend/pages/_app.tsx @@ -13,7 +13,11 @@ import { AppProviders } from "@web/components/AppProviders" import ErrorBoundary from "@web/components/ErrorBoundary" import { ConfigurationGuard } from "@web/components/networking/ConfigurationGuard" import { ServerReadinessGate } from "@web/components/networking/ServerReadinessGate" -import { hasEnvApiAuth } from "@web/lib/authStorage" +import { + getRuntimeApiBearer, + getRuntimeApiKey, + hasEnvApiAuth +} from "@web/lib/authStorage" import { loadTldwAuth, loadTldwClient } from "@web/lib/configured-auth-state" import { buildFirstRunOnboardingRoute, @@ -179,7 +183,10 @@ export default function App({ Component, pageProps }: AppProps) { await runtimeBootstrapReady.catch(() => undefined) if (cancelled) return - const envAuthed = hasEnvApiAuth() + const envAuthed = + hasEnvApiAuth() || + Boolean(getRuntimeApiKey()) || + Boolean(getRuntimeApiBearer()) const configuredAuth = await getConfiguredAuthState() const authed = configuredAuth.hasConfig ? configuredAuth.authMode === "multi-user" diff --git a/backlog/tasks/task-12127 - Fix-WebUI-auth-persistence-after-runtime-credential-scrub.md b/backlog/tasks/task-12127 - Fix-WebUI-auth-persistence-after-runtime-credential-scrub.md new file mode 100644 index 0000000000..1f2032245f --- /dev/null +++ b/backlog/tasks/task-12127 - Fix-WebUI-auth-persistence-after-runtime-credential-scrub.md @@ -0,0 +1,49 @@ +--- +id: TASK-12127 +title: Fix WebUI auth persistence after runtime credential scrub +status: Done +priority: High +references: +- https://github.com/rmusser01/tldw_server/issues/2590 +modified_files: +- apps/tldw-frontend/extension/shims/runtime-bootstrap.ts +- apps/tldw-frontend/__tests__/extension/runtime-bootstrap.test.ts +- apps/tldw-frontend/pages/_app.tsx +- apps/tldw-frontend/__tests__/app/app-layout.test.tsx +--- + +## Description + + +Investigate and fix GitHub issue #2590: manually entered single-user WebUI auth is stripped from persisted tldwConfig and lost on the second hard reload when no runtime-config or build-time auth re-supplies it. Product decision: users should stay authenticated across hard reloads without re-entering the key. + + +## Acceptance Criteria + +- [x] #1 Manually entered single-user API key remains available to runtime auth after a second hard reload. +- [x] #2 Persisted tldwConfig does not regain clear-text apiKey/accessToken fields. +- [x] #3 Runtime-config and build-time auth precedence remains unchanged. +- [x] #4 Focused frontend regression tests pass. + + +## Implementation Notes + + +Added a sessionStorage-backed runtime auth bridge for manually entered single-user API keys. The bootstrap captures a valid manual key before scrubbing tldwConfig, keeps it only for the current browser session, and rehydrates the runtime override on later hard reloads when no runtime-config or build-time auth is present. The session fallback also covers blank or placeholder apiKey values persisted by Settings, sessionStorage access failures now log sanitized warnings, and the derivation logic is isolated in a helper. The shell auth gate treats runtime auth material as authenticated so header/sidebar chrome remains visible after the scrub. Added coverage for clearing the session key when stored auth switches away from single-user mode. + + +## Final Summary + + +Fixed issue #2590 for the confirmed same-browser-session scope. Manual single-user auth survives a second hard reload through runtime session auth while tldwConfig remains scrubbed, including when Settings persists a blank single-user apiKey. Added regression coverage for the second-reload runtime bootstrap path, blank-key session rehydrate path, mode-switch session clear path, and WebUI shell gate. Verification: bunx vitest run __tests__/extension/runtime-bootstrap.test.ts __tests__/app/app-layout.test.tsx --config vitest.config.ts (45 tests passed); bunx eslint extension/shims/runtime-bootstrap.ts __tests__/extension/runtime-bootstrap.test.ts pages/_app.tsx __tests__/app/app-layout.test.tsx (0 errors, 1 pre-existing no-explicit-any warning); git diff --check passed. Full bun run typecheck currently fails in unrelated e2e files: e2e/fixtures/knowledge-qa-live.ts and e2e/workflows/tier-2-features/flashcards.spec.ts. Bandit skipped because touched code is frontend TypeScript, not Python. + + +## Definition of Done + +- [x] #1 Acceptance criteria completed +- [x] #2 Tests or verification recorded +- [x] #3 Documentation updated when relevant +- [x] #4 Bandit run for touched code when applicable or document non-code/environment skip +- [x] #5 Final summary added +- [x] #6 Known skips or blockers documented +