Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/tldw-frontend/__tests__/app/app-layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@web/lib/authStorage")>()
return {
...actual,
getRuntimeApiKey: () => mockRuntimeApiKey
}
})

import App from "@web/pages/_app"

const mockRouter = {
Expand Down Expand Up @@ -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()
})

Expand Down Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions apps/tldw-frontend/__tests__/extension/runtime-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"

Expand Down
70 changes: 62 additions & 8 deletions apps/tldw-frontend/extension/shims/runtime-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

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,
Expand Down Expand Up @@ -494,6 +547,7 @@ const seedTldwConfigFromEnv = async (): Promise<void> => {
if (envAuthOptedOut) {
setRuntimeApiKey(null)
setRuntimeSingleUserApiKeyOverride(null)
writeRuntimeSessionApiKey(null)
} else if (apiKey && !getRuntimeSingleUserApiKeyOverride()) {
setRuntimeApiKey(apiKey)
setRuntimeSingleUserApiKeyOverride(apiKey)
Expand All @@ -513,20 +567,20 @@ const seedTldwConfigFromEnv = async (): Promise<void> => {
const existing = (await storage.get<TldwConfig>("tldwConfig").catch(() => null)) || null
const storedServerUrl =
(await storage.get<string>("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)
Comment thread
rmusser01 marked this conversation as resolved.
}
const quickstartWebUiServerUrl = getQuickstartWebUiServerUrl()
const effectiveExplicitWebHost =
Expand Down
11 changes: 9 additions & 2 deletions apps/tldw-frontend/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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

<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->

## Implementation Notes

<!-- SECTION:IMPLEMENTATION_NOTES:BEGIN -->
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.
<!-- SECTION:IMPLEMENTATION_NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [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
<!-- DOD:END -->
Loading