From 202822f3ba5f7a38fe5924eb4b073bf8b8ce5839 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 17 Aug 2026 02:24:15 +0200 Subject: [PATCH] feat(auth): add remembered local login to the frontend --- frontend/src/App.test.tsx | 12 +- frontend/src/App.tsx | 4 +- frontend/src/api/auth.ts | 114 ++++++++ frontend/src/api/client.test.ts | 271 +++++++++++++++++- frontend/src/api/client.ts | 168 ++++++++++- frontend/src/api/pi-management.ts | 18 +- frontend/src/api/sessions.ts | 47 ++- frontend/src/api/types.ts | 33 +++ frontend/src/api/workspaces.ts | 15 +- frontend/src/auth/AuthGate.test.tsx | 150 ++++++++++ frontend/src/auth/AuthGate.tsx | 111 +++++++ frontend/src/auth/LoginPage.test.tsx | 239 +++++++++++++++ frontend/src/auth/LoginPage.tsx | 161 +++++++++++ frontend/src/auth/authOperation.test.ts | 40 +++ frontend/src/auth/authOperation.ts | 65 +++++ frontend/src/auth/authState.test.ts | 57 ++++ frontend/src/auth/authState.ts | 71 +++++ frontend/src/shell/AppShell.auth.test.tsx | 210 ++++++++++++++ .../src/shell/AppShell.new-session.test.tsx | 6 + .../src/shell/AppShell.notifications.test.tsx | 1 + .../src/shell/AppShell.session-mgmt.test.tsx | 86 +++++- frontend/src/shell/AppShell.tsx | 186 +++++++++--- frontend/src/shell/NewSessionDialog.tsx | 27 +- .../src/shell/SessionDocumentsPanel.test.tsx | 3 +- frontend/src/shell/SteerInput.test.tsx | 136 ++++++++- frontend/src/shell/SteerInput.tsx | 37 ++- .../src/shell/WidgetHost.response.test.tsx | 46 ++- frontend/src/shell/WidgetHost.test.tsx | 13 +- frontend/src/shell/WidgetHost.tsx | 10 +- frontend/src/shell/WorkspaceManager.test.tsx | 70 ++++- frontend/src/shell/WorkspaceManager.tsx | 127 ++++++-- frontend/src/shell/f1-loop.test.tsx | 2 +- frontend/src/stream/useSessionStream.test.tsx | 200 ++++++++++++- frontend/src/stream/useSessionStream.ts | 66 ++++- frontend/src/test/fakeEventSource.ts | 2 +- frontend/src/test/msw.ts | 8 + 36 files changed, 2649 insertions(+), 163 deletions(-) create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/auth/AuthGate.test.tsx create mode 100644 frontend/src/auth/AuthGate.tsx create mode 100644 frontend/src/auth/LoginPage.test.tsx create mode 100644 frontend/src/auth/LoginPage.tsx create mode 100644 frontend/src/auth/authOperation.test.ts create mode 100644 frontend/src/auth/authOperation.ts create mode 100644 frontend/src/auth/authState.test.ts create mode 100644 frontend/src/auth/authState.ts create mode 100644 frontend/src/shell/AppShell.auth.test.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0ad1a8e5..b9b03201 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,7 +1,15 @@ import { render, screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "./test/msw"; import { App } from "./App"; -test("App renders the shell with create affordance", () => { +test("App renders the shell with create affordance after authentication", async () => { + server.use( + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })), + http.get("/api/workspaces", () => HttpResponse.json([])), + http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.get("/api/sessions", () => HttpResponse.json([])), + ); render(); - expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /new/i })).toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 97b8d367..3ac9b56c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,11 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./app/queryClient"; -import { AppShell } from "./shell/AppShell"; +import { AuthGate } from "./auth/AuthGate"; export function App() { return ( - + ); } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 00000000..77636168 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,114 @@ +import { ApiError, apiFetch } from "./client"; +import type { AuthenticatedUser, AuthPublicConfig, AuthRole, AuthSessionInfo } from "./types"; +import { clearAuthStateIfCurrent, getAuthGeneration, setAuthState } from "../auth/authState"; + +const authModes = new Set(["local", "oidc", "upstream", "none", "mock"]); +const roles = new Set(["user", "admin"]); +const sessionMethods = new Set(["local", "oidc", "upstream"]); +let pendingLogoutResponse: Promise | null = null; + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function parseAuthConfig(value: unknown): AuthPublicConfig { + const source = record(value); + if (!source || !authModes.has(source.mode as AuthPublicConfig["mode"]) + || typeof source.localLogin !== "boolean" || typeof source.oidcLogin !== "boolean") { + throw new Error("Authentication service returned an invalid public configuration"); + } + return { + mode: source.mode as AuthPublicConfig["mode"], + localLogin: source.localLogin, + oidcLogin: source.oidcLogin, + }; +} + +function parseUser(value: unknown): AuthenticatedUser { + const source = record(value); + if (!source || typeof source.issuer !== "string" || typeof source.subject !== "string") { + throw new Error("Authentication service returned an invalid user"); + } + const parsedRoles = Array.isArray(source.roles) + ? source.roles.filter((role): role is AuthRole => typeof role === "string" && roles.has(role as AuthRole)) + : []; + const parsedPermissions = Array.isArray(source.permissions) + ? source.permissions.filter((permission): permission is string => typeof permission === "string") + : []; + const csrfToken = source.csrfToken === undefined || source.csrfToken === null + ? null + : typeof source.csrfToken === "string" ? source.csrfToken : null; + const sessionSource = source.session === null || source.session === undefined + ? undefined + : record(source.session); + const session = sessionSource + && sessionMethods.has(sessionSource.method as AuthSessionInfo["method"]) + && typeof sessionSource.remembered === "boolean" + && typeof sessionSource.idleExpiresAt === "string" + && typeof sessionSource.absoluteExpiresAt === "string" + ? { + method: sessionSource.method as AuthSessionInfo["method"], + remembered: sessionSource.remembered, + idleExpiresAt: sessionSource.idleExpiresAt, + absoluteExpiresAt: sessionSource.absoluteExpiresAt, + } + : null; + return { + issuer: source.issuer, + subject: source.subject, + ...(typeof source.displayName === "string" ? { displayName: source.displayName } : {}), + roles: parsedRoles, + permissions: parsedPermissions, + isAdmin: source.isAdmin === true, + csrfToken, + session, + }; +} + +export async function getAuthConfig(): Promise { + return parseAuthConfig(await apiFetch("/auth/config")); +} + +export async function getMe(): Promise { + return parseUser(await apiFetch("/me")); +} + +export async function loginLocal(username: string, password: string, remember: boolean): Promise { + if (pendingLogoutResponse) await pendingLogoutResponse; + await apiFetch("/auth/local/login", { + method: "POST", + body: JSON.stringify({ username, password, remember }), + }); + const user = await getMe(); + setAuthState(user); + return user; +} + +export async function logout(): Promise { + const logoutGeneration = getAuthGeneration(); + if (pendingLogoutResponse) { + await pendingLogoutResponse; + return false; + } + let cleared = false; + const response = (async () => { + try { + await apiFetch("/auth/logout", { method: "POST" }); + } finally { + cleared = clearAuthStateIfCurrent(logoutGeneration); + } + })(); + let trackedResponse: Promise; + trackedResponse = response.finally(() => { + if (pendingLogoutResponse === trackedResponse) pendingLogoutResponse = null; + }); + pendingLogoutResponse = trackedResponse; + await trackedResponse; + return cleared; +} + +export function authErrorStatus(error: unknown): number | undefined { + return error instanceof ApiError ? error.status : undefined; +} diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 83d9094c..7f6474f2 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,6 +1,20 @@ import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; -import { apiFetch } from "./client"; +import { apiErrorMessage, apiFetch, ApiError, assertSameOriginRequestUrl } from "./client"; +import { clearAuthState, setAuthState } from "../auth/authState"; + +const authenticated = { + issuer: "local", + subject: "user-1", + roles: ["user"] as const, + permissions: ["session.use"], + isAdmin: false, + csrfToken: "c".repeat(43), + session: null, +}; + +beforeEach(() => setAuthState(authenticated)); +afterEach(() => clearAuthState()); test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => { let contentType: string | null = "unset"; @@ -25,3 +39,258 @@ test("POST with a body sends application/json content-type", async () => { await apiFetch("/sessions/s1/steer", { method: "POST", body: JSON.stringify({ text: "hi" }) }); expect(contentType).toContain("application/json"); }); + +test("same-origin requests include credentials and overwrite the CSRF header from memory", async () => { + let observed: { credentials: string | null; csrf: string | null } | undefined; + const fetchSpy = vi.spyOn(globalThis, "fetch"); + server.use( + http.post("/api/sessions/s1/steer", ({ request }) => { + observed = { + credentials: request.headers.get("credentials"), + csrf: request.headers.get("x-thothii-csrf"), + }; + return new HttpResponse(null, { status: 204 }); + }), + ); + + await apiFetch("/sessions/s1/steer", { + method: "POST", + headers: { "X-ThothII-CSRF": "attacker-supplied" }, + body: JSON.stringify({ text: "hi" }), + }); + + expect(observed?.csrf).toBe("c".repeat(43)); + expect(observed?.credentials).toBeNull(); + expect(fetchSpy.mock.calls.at(-1)?.[1]).toMatchObject({ credentials: "same-origin" }); + fetchSpy.mockRestore(); +}); + +test("a 401 clears the in-memory auth state and advances its generation", async () => { + const before = (await import("../auth/authState")).getAuthGeneration(); + server.use(http.get("/api/private", () => new HttpResponse(null, { status: 401 }))); + + await expect(apiFetch("/private")).rejects.toMatchObject({ status: 401 }); + + const state = await import("../auth/authState"); + expect(state.getAuthState()).toBeNull(); + expect(state.getAuthGeneration()).toBeGreaterThan(before); +}); + +test("a delayed 401 from user A cannot clear user B after a new login", async () => { + let release!: () => void; + const delayed = new Promise((resolve) => { release = resolve; }); + server.use(http.get("/api/stale-request", async () => { + await delayed; + return new HttpResponse(null, { status: 401 }); + })); + + const request = apiFetch("/stale-request"); + const userB = { ...authenticated, subject: "user-b", csrfToken: "b".repeat(43) }; + setAuthState(userB); + release(); + + await expect(request).rejects.toMatchObject({ status: 401 }); + expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" }); +}); + +test("bounds streamed error bodies and never exposes raw HTML or secrets", async () => { + const secret = "TOP-SECRET-token-123"; + const hugeBody = `${secret}${"x".repeat(20_000)}`; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(hugeBody.slice(0, 4_000))); + controller.enqueue(new TextEncoder().encode(hugeBody.slice(4_000))); + controller.close(); + }, + }), { status: 500, headers: { "content-type": "text/html", "content-length": "1" } }), + ); + + try { + const result = await apiFetch("/oversized").catch((error: unknown) => error); + expect(result).toBeInstanceOf(ApiError); + const failure = result as ApiError; + expect(failure.status).toBe(500); + expect(failure.message).not.toContain(secret); + expect(failure.message).not.toContain(""); + expect(failure.bodyText).not.toContain(secret); + expect(failure.payload).toBeUndefined(); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("keeps only a known safe bounded JSON error payload", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ + code: "dwh_unreachable", + error: "The data warehouse is unreachable.", + }), { status: 503, headers: { "content-type": "application/json" } }), + ); + + try { + const result = await apiFetch("/known-error").catch((error: unknown) => error); + const failure = result as ApiError; + expect(failure).toMatchObject({ + status: 503, + code: "dwh_unreachable", + payload: { code: "dwh_unreachable" }, + }); + expect(failure.bodyText).toBe(""); + expect(failure.message).toBe("The database is unreachable. Please retry."); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("derives local messages without retaining a malicious known-code message", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ + code: "dwh_unreachable", + message: "Bearer eyJhbGciOiJIUzI1NiJ9.password=do-not-show", + error: "password=do-not-show", + }), { status: 503, headers: { "content-type": "application/json" } }), + ); + + try { + const failure = await apiFetch("/known-malicious").catch((error: unknown) => error) as ApiError; + expect(failure.code).toBe("dwh_unreachable"); + expect(failure.message).toBe("The database is unreachable. Please retry."); + expect(failure.bodyText).toBe(""); + expect(failure.payload).toEqual({ code: "dwh_unreachable" }); + expect(failure.message).not.toMatch(/Bearer|password|html|do-not-show/i); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("uses a generic local message for unknown malicious codes", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ + code: "unknown-secret-code", + message: "Bearer eyJhbGciOiJIUzI1NiJ9", + error: "password=do-not-show", + }), { status: 500, headers: { "content-type": "application/json" } }), + ); + + try { + const failure = await apiFetch("/unknown-malicious").catch((error: unknown) => error) as ApiError; + expect(failure.code).toBeUndefined(); + expect(failure.message).toBe("Request failed. Please try again."); + expect(failure.bodyText).toBe(""); + expect(failure.payload).toBeUndefined(); + expect(apiErrorMessage(failure)).toBe("Request failed. Please try again."); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("releases the response reader after a successful bounded read", async () => { + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode("{not-json") }) + .mockResolvedValueOnce({ done: true, value: undefined }), + cancel: vi.fn().mockResolvedValue(undefined), + releaseLock: vi.fn(), + }; + const response = new Response(new ReadableStream(), { status: 500 }); + vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader>); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response); + + try { + await expect(apiFetch("/reader-success")).rejects.toBeInstanceOf(ApiError); + expect(reader.cancel).not.toHaveBeenCalled(); + expect(reader.releaseLock).toHaveBeenCalledOnce(); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("cancels and releases the response reader on overflow", async () => { + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ done: false, value: new Uint8Array(9 * 1024) }), + cancel: vi.fn().mockResolvedValue(undefined), + releaseLock: vi.fn(), + }; + const response = new Response(new ReadableStream(), { status: 500 }); + vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader>); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response); + + try { + await expect(apiFetch("/reader-overflow")).rejects.toBeInstanceOf(ApiError); + expect(reader.cancel).toHaveBeenCalledOnce(); + expect(reader.releaseLock).toHaveBeenCalledOnce(); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("cancels and releases the response reader when a read throws", async () => { + const reader = { + read: vi.fn().mockRejectedValue(new Error("stream broke")), + cancel: vi.fn().mockResolvedValue(undefined), + releaseLock: vi.fn(), + }; + const response = new Response(new ReadableStream(), { status: 500 }); + vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader>); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response); + + try { + await expect(apiFetch("/reader-throws")).rejects.toThrow("stream broke"); + expect(reader.cancel).toHaveBeenCalledOnce(); + expect(reader.releaseLock).toHaveBeenCalledOnce(); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("rejects malformed and sensitive JSON error bodies without surfacing their content", async () => { + const bodies = [ + "{not-json", + JSON.stringify({ code: "unknown_secret_code", error: "password=super-secret" }), + ]; + + for (const body of bodies) { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(body, { status: 500, headers: { "content-type": "application/json" } }), + ); + try { + const result = await apiFetch("/unsafe-error").catch((error: unknown) => error); + const failure = result as ApiError; + expect(failure.payload).toBeUndefined(); + expect(failure.message).not.toContain("super-secret"); + expect(failure.message).not.toContain("not-json"); + } finally { + fetchSpy.mockRestore(); + } + } + + const knownCode = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ code: "auth_forbidden", error: "token=super-secret" }), { + status: 403, headers: { "content-type": "application/json" }, + }), + ); + try { + const result = await apiFetch("/known-sensitive-error").catch((error: unknown) => error); + const failure = result as ApiError; + expect(failure.payload).toEqual({ code: "auth_forbidden" }); + expect(failure.message).toBe("Access is not permitted."); + expect(failure.message).not.toContain("super-secret"); + } finally { + knownCode.mockRestore(); + } +}); + +test("refuses a cross-origin request before sending credentials", () => { + expect(() => assertSameOriginRequestUrl("https://attacker.example/api/me")).toThrow(/same-origin/i); +}); + +test("preserves explicit 403 and 503 statuses for presentation", async () => { + server.use( + http.get("/api/forbidden", () => HttpResponse.json({ code: "auth_forbidden" }, { status: 403 })), + http.get("/api/unavailable", () => HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })), + ); + await expect(apiFetch("/forbidden")).rejects.toBeInstanceOf(ApiError); + await expect(apiFetch("/unavailable")).rejects.toMatchObject({ status: 503 }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a84ea83a..ec92d02b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,15 +1,137 @@ import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config"; +import { + clearAuthStateIfCurrent, + getAuthGeneration, + getAuthState, +} from "../auth/authState"; + +const MAX_ERROR_BODY_BYTES = 8 * 1024; +const safeErrorCodes = new Set([ + "auth_forbidden", "auth_invalid_credentials", "auth_not_authorized", "auth_unavailable", + "auth_not_implemented", "authentication_required", "invalid_credentials", "login_rate_limited", + "csrf_failed", "csrf_invalid", "dwh_unreachable", "model_unavailable", + "workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale", + "git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable", + "semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable", + "pi_management_invalid_config", "pi_management_write_failed", +]); + +type SafeErrorPayload = { + code: string; +}; + +const localCodeMessages: Record = { + auth_forbidden: "Access is not permitted.", + auth_invalid_credentials: "Invalid username or password.", + auth_not_authorized: "Access is not permitted.", + auth_unavailable: "Authentication is temporarily unavailable. Try again.", + auth_not_implemented: "This sign-in method is not available.", + authentication_required: "Please sign in to continue.", + invalid_credentials: "Invalid username or password.", + login_rate_limited: "Too many sign-in attempts. Try again later.", + csrf_failed: "The security check failed. Please retry.", + csrf_invalid: "The security check failed. Please retry.", + dwh_unreachable: "The database is unreachable. Please retry.", + model_unavailable: "The model provider is unavailable. Please retry.", + workspace_invalid: "The workspace configuration is invalid.", + binding_missing: "The workspace is missing a required binding.", + workspace_not_activatable: "The workspace cannot be activated.", + workspace_stale: "The workspace has changed. Refresh and try again.", + git_unavailable: "The workspace repository is unavailable.", + git_auth_failed: "The workspace repository could not be authenticated.", + git_non_fast_forward: "The workspace repository has moved. Refresh and try again.", + connector_unavailable: "A workspace connector is unavailable.", + semantic_index_incompatible: "The workspace semantic index is incompatible.", + pi_management_forbidden: "Pi management is not permitted", + pi_management_unavailable: "Pi management is unavailable.", + pi_management_invalid_config: "The Pi configuration is invalid.", + pi_management_write_failed: "The Pi configuration could not be saved.", +}; + +const localStatusMessages: Record = { + 401: "Please sign in to continue.", + 403: "Access is not permitted.", + 404: "The requested resource was not found.", + 409: "The request conflicts with current workspace state.", + 429: "Too many requests. Try again later.", + 500: "Request failed. Please try again.", + 502: "The service is unavailable. Please retry.", + 503: "The service is temporarily unavailable. Please retry.", +}; + +const GENERIC_ERROR_MESSAGE = "Request failed. Please try again."; + +function localErrorMessage(status: number, code?: string): string { + return (code && localCodeMessages[code]) || localStatusMessages[status] || GENERIC_ERROR_MESSAGE; +} /** - * Error thrown for non-2xx responses. `.message` stays " " for - * backward compatibility; `.status` and `.payload` (parsed JSON body, if any) - * let callers branch on a specific failure — e.g. a `code: "dwh_unreachable"`. + * Error thrown for non-2xx responses. The message contains only status and a + * whitelisted error code; `.payload` contains a bounded, sanitized JSON shape. */ export class ApiError extends Error { - constructor(readonly status: number, readonly bodyText: string, readonly payload: unknown) { - super(`${status} ${bodyText}`); + constructor( + readonly status: number, + readonly bodyText: string, + readonly payload: SafeErrorPayload | undefined, + ) { + super(localErrorMessage(status, payload?.code)); this.name = "ApiError"; } + + get code(): string | undefined { + return this.payload?.code; + } +} + +export function apiErrorMessage(error: unknown): string { + return error instanceof ApiError ? error.message : GENERIC_ERROR_MESSAGE; +} + +function parseSafeErrorPayload(text: string, truncated: boolean): SafeErrorPayload | undefined { + if (truncated || text.length === 0) return undefined; + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { return undefined; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const source = parsed as Record; + if (typeof source.code !== "string" || !safeErrorCodes.has(source.code)) return undefined; + return { code: source.code }; +} + +async function readBoundedText(response: Response): Promise<{ text: string; truncated: boolean }> { + const reader = response.body?.getReader(); + if (!reader) return { text: "", truncated: false }; + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + let truncated = false; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + const remaining = MAX_ERROR_BODY_BYTES - bytes; + if (remaining <= 0) { + truncated = true; + await reader.cancel(); + break; + } + const chunk = next.value.byteLength > remaining ? next.value.slice(0, remaining) : next.value; + bytes += chunk.byteLength; + text += decoder.decode(chunk, { stream: next.value.byteLength <= remaining }); + if (next.value.byteLength > remaining) { + truncated = true; + await reader.cancel(); + break; + } + } + } catch (error) { + try { await reader.cancel(); } catch { /* preserve the original read failure */ } + throw error; + } finally { + text += decoder.decode(); + try { reader.releaseLock(); } catch { /* a completed browser reader may already be released */ } + } + return { text, truncated }; } function requestHeaders(init: RequestInit | undefined): Headers { @@ -27,16 +149,42 @@ function requestHeaders(init: RequestInit | undefined): Headers { } async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(joinBackendPath(BASE, path), { ...init, headers: requestHeaders(init) }); + const url = joinBackendPath(BASE, path); + assertSameOriginRequestUrl(url); + const dispatchGeneration = getAuthGeneration(); + const headers = requestHeaders(init); + const method = (init?.method ?? "GET").toUpperCase(); + if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + headers.delete("X-ThothII-CSRF"); + const csrfToken = getAuthState()?.csrfToken; + if (csrfToken) headers.set("X-ThothII-CSRF", csrfToken); + } + const res = await fetch(url, { + ...init, + credentials: "same-origin", + headers, + }); + if (res.status === 401) clearAuthStateIfCurrent(dispatchGeneration); if (!res.ok) { - const bodyText = await res.text().catch(() => ""); - let payload: unknown; - try { payload = bodyText ? JSON.parse(bodyText) : undefined; } catch { payload = undefined; } - throw new ApiError(res.status, bodyText, payload); + const { text, truncated } = await readBoundedText(res); + const payload = parseSafeErrorPayload(text, truncated); + throw new ApiError(res.status, "", payload); } return res; } +/** Refuse a credentialed cross-origin base before the browser can send a request. */ +export function assertSameOriginRequestUrl(url: string): void { + if (typeof window === "undefined") { + if (/^https?:\/\//i.test(url)) throw new Error("The browser must use the same-origin /api route"); + return; + } + const parsed = new URL(url, window.location.origin); + if (parsed.origin !== window.location.origin) { + throw new Error("The browser must use the same-origin /api route"); + } +} + export async function apiFetch(path: string, init?: RequestInit): Promise { // Only declare a JSON content-type when we actually send a body. Body-less // POSTs (resume, close) would otherwise make Fastify reject the empty body diff --git a/frontend/src/api/pi-management.ts b/frontend/src/api/pi-management.ts index 78008d75..2b12a801 100644 --- a/frontend/src/api/pi-management.ts +++ b/frontend/src/api/pi-management.ts @@ -1,4 +1,4 @@ -import { ApiError, apiFetch } from "./client"; +import { ApiError, apiErrorMessage, apiFetch } from "./client"; export type PiReasoning = "low" | "medium" | "high"; @@ -54,22 +54,14 @@ const errorCodes = new Set([ "pi_management_write_failed", ]); -function object(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; -} - -/** Narrows the sanctioned error payload without ever surfacing its raw response body. */ +/** Narrows the sanctioned error code and derives its message locally. */ export function asPiManagementApiError(error: unknown): PiManagementApiError | undefined { if (!(error instanceof ApiError)) return undefined; - const payload = object(error.payload); - const code = payload?.code; - const message = payload?.error; - if (typeof code !== "string" || !errorCodes.has(code as PiManagementApiErrorCode) || typeof message !== "string") { + const code = error.code; + if (typeof code !== "string" || !errorCodes.has(code as PiManagementApiErrorCode)) { return undefined; } - return { status: error.status, code: code as PiManagementApiErrorCode, message }; + return { status: error.status, code: code as PiManagementApiErrorCode, message: apiErrorMessage(error) }; } export const getPiManagementStatus = () => apiFetch("/pi-management/status"); diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 30e918e1..7284db61 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,4 +1,10 @@ import { apiFetch } from "./client"; +import { + captureAuthOperation, + isAuthOperationCurrent, + requireAuthOperationPrecondition, + type AuthOperationPrecondition, +} from "../auth/authOperation"; import { getSettings } from "./settings"; import { getWorkspace, listWorkspaces } from "./workspaces"; import { @@ -6,18 +12,20 @@ import { workspacePreferences, type WorkspacePreference, } from "../workspaces/drafts"; import type { - Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse, + AuthenticatedUser, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse, } from "./types"; type NewSessionInput = { question: string; name?: string } & Partial; -async function selectedPreferences(): Promise { +async function selectedPreferences(precondition?: AuthOperationPrecondition): Promise { + requireAuthOperationPrecondition(precondition); const saved = workspacePreferences.load(); if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved; // A direct new-session entry point can run before the composer has mounted. Seed current // application memory from the installation defaults once, then keep choices ephemeral. const legacy = await getSettings(); + requireAuthOperationPrecondition(precondition); return workspacePreferences.save({ workspaceId: saved.workspaceId ?? legacy.workspace, provider: saved.provider ?? legacy.provider, @@ -44,8 +52,9 @@ function reconcileWorkspacePolicy(preferences: WorkspacePreference, allowed: rea }); } -async function ensureWorkspaceSelectionPolicy(): Promise { +async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecondition): Promise { while (true) { + requireAuthOperationPrecondition(precondition); const preferences = workspacePreferences.load(); const workspaceId = preferences.workspaceId; if (!workspaceId) return preferences; @@ -54,10 +63,12 @@ async function ensureWorkspaceSelectionPolicy(): Promise { try { workspaces = await listWorkspaces(); } catch { + requireAuthOperationPrecondition(precondition); if (workspacePreferences.load().workspaceId !== workspaceId) continue; workspacePolicyGate.rejectSummary(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR); } + requireAuthOperationPrecondition(precondition); if (workspacePreferences.load().workspaceId !== workspaceId) continue; const workspace = workspaces.find((candidate) => candidate.id === workspaceId); if (!workspace) { @@ -69,18 +80,22 @@ async function ensureWorkspaceSelectionPolicy(): Promise { try { installationDefault = (await getSettings()).workspace; } catch { + requireAuthOperationPrecondition(precondition); if (workspacePreferences.load().workspaceId !== workspaceId) continue; workspacePolicyGate.rejectSummary(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR); } + requireAuthOperationPrecondition(precondition); if (workspacePreferences.load().workspaceId !== workspaceId) continue; const replacement = workspaces.find( (candidate) => candidate.id === installationDefault && candidate.revision, ) ?? workspaces.find((candidate) => candidate.revision); if (!replacement) { + requireAuthOperationPrecondition(precondition); workspacePolicyGate.reject(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); } + requireAuthOperationPrecondition(precondition); workspacePreferences.save({ ...preferences, workspaceId: replacement.id }); continue; } @@ -98,16 +113,19 @@ async function ensureWorkspaceSelectionPolicy(): Promise { (selection) => ({ kind: "selection" as const, selection }), ), ]); + requireAuthOperationPrecondition(precondition); if (outcome.kind === "selection") { if (outcome.selection.workspaceId !== workspaceId) continue; return outcome.selection; } if (outcome.kind === "error") { + requireAuthOperationPrecondition(precondition); if (workspacePreferences.load().workspaceId !== workspaceId) continue; workspacePolicyGate.reject(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); } const { record } = outcome; + requireAuthOperationPrecondition(precondition); if (workspacePreferences.load().workspaceId !== workspaceId) continue; let selection: WorkspacePreference; try { @@ -117,17 +135,31 @@ async function ensureWorkspaceSelectionPolicy(): Promise { record.workspace.llm_policy.default, ); } catch (error) { + requireAuthOperationPrecondition(precondition); workspacePolicyGate.reject(workspaceId); throw error; } + requireAuthOperationPrecondition(precondition); workspacePolicyGate.resolve(workspaceId); + requireAuthOperationPrecondition(precondition); if (workspacePreferences.load().workspaceId === workspaceId) return selection; } } -export async function createSession(i: NewSessionInput) { - await selectedPreferences(); - const selection = await ensureWorkspaceSelectionPolicy(); +export async function createSession(i: NewSessionInput, precondition?: AuthOperationPrecondition) { + const initiatingOperation = captureAuthOperation(); + const effectivePrecondition = precondition ?? (initiatingOperation ? { + operation: initiatingOperation, + isCurrent: () => isAuthOperationCurrent(initiatingOperation, { + sessionId: null, + disposalEpoch: initiatingOperation.disposalEpoch, + }), + } : undefined); + requireAuthOperationPrecondition(effectivePrecondition); + await selectedPreferences(effectivePrecondition); + requireAuthOperationPrecondition(effectivePrecondition); + const selection = await ensureWorkspaceSelectionPolicy(effectivePrecondition); + requireAuthOperationPrecondition(effectivePrecondition); return apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify({ ...i, ...selection }), @@ -138,7 +170,8 @@ export async function createSession(i: NewSessionInput) { export const prewarmRuntime = () => apiFetch("/runtime/prewarm", { method: "POST" }); -export const getMe = () => apiFetch("/me"); +/** Compatibility export for existing shell tests/callers; AuthGate uses the validated auth API. */ +export const getMe = () => apiFetch("/me"); export const listSessions = (scope: SessionScope = "mine") => apiFetch(`/sessions?scope=${scope}`); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1f6b05d4..61d1b4f1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -117,11 +117,44 @@ export interface SessionSummary { export type SessionScope = "mine" | "all"; +export type AuthRole = "user" | "admin"; + +export interface AuthSessionInfo { + method: "local" | "oidc" | "upstream"; + remembered: boolean; + idleExpiresAt: string; + absoluteExpiresAt: string; +} + +/** Safe browser representation returned by Task 8's /me route. */ +export interface AuthenticatedUser { + issuer: string; + subject: string; + displayName?: string; + roles: readonly AuthRole[]; + permissions: readonly string[]; + isAdmin: boolean; + /** Legacy upstream/none modes deliberately return null: there is no CSRF token. */ + csrfToken: string | null; + /** Legacy upstream/none modes deliberately return null: there is no cookie session. */ + session: AuthSessionInfo | null; +} + +export interface AuthPublicConfig { + mode: "local" | "oidc" | "upstream" | "none" | "mock"; + localLogin: boolean; + oidcLogin: boolean; +} + export interface Principal { issuer: string; subject: string; displayName?: string; + roles?: readonly AuthRole[]; + permissions?: readonly string[]; isAdmin: boolean; + csrfToken?: string | null; + session?: AuthSessionInfo | null; } export interface ResumeSessionResult { diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index f868b502..93c29816 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -1,4 +1,4 @@ -import { ApiError, apiFetch } from "./client"; +import { ApiError, apiErrorMessage, apiFetch } from "./client"; import { sanitizeCanonicalWorkspace } from "../workspaces/drafts"; export type WorkspaceErrorCode = @@ -238,19 +238,14 @@ function requireWorkspaceRevision(value: unknown, expectedId: string): Workspace return revision; } -/** Sanitized registry error data; it intentionally excludes the raw response body. */ +/** Localized registry error data; it intentionally excludes the raw response body. */ export function asWorkspaceApiError(error: unknown): WorkspaceApiError | undefined { if (!(error instanceof ApiError)) return undefined; - const payload = object(error.payload); - const code = payload?.code; - const message = payload?.message; - if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode) || typeof message !== "string") { + const code = error.code; + if (typeof code !== "string" || !workspaceErrorCodes.has(code as WorkspaceErrorCode)) { return undefined; } - const fields = Array.isArray(payload?.fields) && payload.fields.every((field) => typeof field === "string") - ? payload.fields - : undefined; - return { status: error.status, code: code as WorkspaceErrorCode, message, ...(fields ? { fields } : {}) }; + return { status: error.status, code: code as WorkspaceErrorCode, message: apiErrorMessage(error) }; } function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace { diff --git a/frontend/src/auth/AuthGate.test.tsx b/frontend/src/auth/AuthGate.test.tsx new file mode 100644 index 00000000..3a022ce4 --- /dev/null +++ b/frontend/src/auth/AuthGate.test.tsx @@ -0,0 +1,150 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse, delay } from "msw"; +import { beforeEach, afterEach, describe, expect, test, vi } from "vitest"; +import { StrictMode } from "react"; +import { AuthGate } from "./AuthGate"; +import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "./authState"; +import { server } from "../test/msw"; + +vi.mock("../shell/AppShell", () => ({ + AppShell: () => ( +
+ Authenticated shell + +
+ ), +})); + +const user = { + issuer: "local", + subject: "user-1", + displayName: "Analyst", + roles: ["user"] as const, + permissions: ["session.use"], + isAdmin: false, + csrfToken: "c".repeat(43), + session: { + method: "local" as const, + remembered: false, + idleExpiresAt: "2026-08-17T10:00:00.000Z", + absoluteExpiresAt: "2026-08-17T20:00:00.000Z", + }, +}; + +const localConfig = { mode: "local", localLogin: true, oidcLogin: false }; + +beforeEach(() => { + clearAuthState(); + server.use( + http.get("/api/auth/config", () => HttpResponse.json(localConfig)), + http.get("/api/me", () => HttpResponse.json(user)), + ); +}); + +afterEach(() => { + cleanup(); + clearAuthState(); +}); + +describe("AuthGate", () => { + test("shows a loading state while /me is unresolved", async () => { + server.use(http.get("/api/me", async () => { + await delay(100); + return HttpResponse.json(user); + })); + + render(); + + expect(screen.getByRole("status", { name: /checking access/i })).toBeInTheDocument(); + expect(screen.queryByTestId("authenticated-shell")).not.toBeInTheDocument(); + }); + + test("renders the authenticated shell from the safe /me DTO", async () => { + render(); + + expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: /sign in/i })).not.toBeInTheDocument(); + }); + + test("returns to local login after an expired session 401", async () => { + server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 }))); + + render(); + + expect(await screen.findByRole("heading", { name: /sign in to thothii/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + + test("presents a forbidden /me response explicitly", async () => { + setAuthState(user); + const generation = getAuthGeneration(); + server.use(http.get("/api/me", () => HttpResponse.json( + { code: "auth_not_authorized", error: "This operation is not permitted" }, + { status: 403 }, + ))); + + render(); + + expect(await screen.findByRole("heading", { name: /access not permitted/i })).toBeInTheDocument(); + expect(screen.getByText(/signed in without permission/i)).toBeInTheDocument(); + expect(getAuthState()).toMatchObject({ subject: "user-1", csrfToken: "c".repeat(43) }); + expect(getAuthGeneration()).toBe(generation); + }); + + test("offers retry when the authentication provider is unavailable", async () => { + let attempts = 0; + server.use( + http.get("/api/me", () => { + attempts += 1; + return attempts === 1 + ? HttpResponse.json({ code: "auth_unavailable" }, { status: 503 }) + : HttpResponse.json(user); + }), + ); + + render(); + + expect(await screen.findByRole("heading", { name: /authentication unavailable/i })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument(); + }); + + test("accepts the nullable legacy /me session shape without inventing a token", async () => { + server.use(http.get("/api/me", () => HttpResponse.json({ + issuer: "portal", + subject: "legacy-user", + displayName: "Legacy user", + roles: ["user"], + permissions: ["session.use"], + isAdmin: false, + csrfToken: null, + session: null, + }))); + + render(); + + expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument(); + }); + + test("does not write credentials or tokens to browser storage", async () => { + const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) => + vi.spyOn(Storage.prototype, method)); + render(); + + await screen.findByTestId("authenticated-shell"); + for (const write of storageWrites) expect(write).not.toHaveBeenCalled(); + for (const write of storageWrites) write.mockRestore(); + }); + + test("does not emit act warnings while StrictMode authenticates", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + render(); + expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument(); + expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i); + } finally { + errors.mockRestore(); + } + }); +}); diff --git a/frontend/src/auth/AuthGate.tsx b/frontend/src/auth/AuthGate.tsx new file mode 100644 index 00000000..19e25d86 --- /dev/null +++ b/frontend/src/auth/AuthGate.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError } from "../api/client"; +import { authErrorStatus, getAuthConfig, getMe } from "../api/auth"; +import type { AuthenticatedUser, AuthPublicConfig } from "../api/types"; +import { AppShell } from "../shell/AppShell"; +import { + clearAuthStateIfCurrent, + getAuthGeneration, + getAuthState, + isAuthGenerationCurrent, + setAuthState, + useAuthGeneration, + useAuthUser, +} from "./authState"; +import { LoginPage } from "./LoginPage"; +import { Button } from "../components/ui/button"; + +type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable"; + +function AuthenticatedContent({ onExpired }: { onExpired: () => void }) { + const user = useAuthUser(); + const authGeneration = useAuthGeneration(); + useEffect(() => { + if (!user) onExpired(); + }, [onExpired, user]); + return user + ? + : null; +} + +export function AuthGate() { + const [status, setStatus] = useState("loading"); + const [config, setConfig] = useState(); + const [attempt, setAttempt] = useState(0); + + const retry = useCallback(() => setAttempt((value) => value + 1), []); + + useEffect(() => { + let cancelled = false; + const load = async () => { + setStatus("loading"); + const loadGeneration = getAuthGeneration(); + try { + const publicConfig = await getAuthConfig(); + if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return; + setConfig(publicConfig); + try { + const authenticated = await getMe(); + if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return; + setAuthState(authenticated); + setStatus("authenticated"); + } catch (error) { + if (cancelled) return; + const statusCode = authErrorStatus(error); + if (statusCode === 401) { + if (isAuthGenerationCurrent(loadGeneration)) clearAuthStateIfCurrent(loadGeneration); + if (getAuthState() === null) setStatus("login"); + return; + } + if (!isAuthGenerationCurrent(loadGeneration)) return; + if (statusCode === 403) setStatus("forbidden"); + else if (statusCode === 503) setStatus("unavailable"); + else setStatus("login"); + } + } catch (error) { + if (cancelled || !isAuthGenerationCurrent(loadGeneration)) return; + if (error instanceof ApiError && error.status === 503) setStatus("unavailable"); + else setStatus("unavailable"); + } + }; + void load(); + return () => { cancelled = true; }; + }, [attempt]); + + if (status === "loading") { + return

Checking access…

; + } + + if (status === "authenticated") { + return setStatus("login")} />; + } + + if (status === "unavailable") { + return ( +
+
+

ThothII access

+

Authentication unavailable

+

The authentication provider could not be reached. Try again in a moment.

+ +
+
+ ); + } + + if (status === "forbidden") { + return ( +
+
+

ThothII access

+

Access not permitted

+

You are signed in without permission to use this workspace. Contact the installation administrator.

+
+
+ ); + } + + return config ? { + setStatus("authenticated"); + }} onRetry={retry} /> : null; +} diff --git a/frontend/src/auth/LoginPage.test.tsx b/frontend/src/auth/LoginPage.test.tsx new file mode 100644 index 00000000..37392323 --- /dev/null +++ b/frontend/src/auth/LoginPage.test.tsx @@ -0,0 +1,239 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse, delay } from "msw"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { StrictMode } from "react"; +import { LoginPage } from "./LoginPage"; +import { clearAuthState, setAuthState } from "./authState"; +import * as authApi from "../api/auth"; +import { server } from "../test/msw"; + +const localConfig = { mode: "local", localLogin: true, oidcLogin: false } as const; +const oidcConfig = { mode: "oidc", localLogin: false, oidcLogin: true } as const; +const authenticated = { + issuer: "local", + subject: "admin-1", + roles: ["admin"] as const, + permissions: ["session.use", "pi.manage"], + isAdmin: true, + csrfToken: "c".repeat(43), + session: { + method: "local" as const, + remembered: true, + idleExpiresAt: "2026-08-17T10:00:00.000Z", + absoluteExpiresAt: "2026-09-16T10:00:00.000Z", + }, +}; + +beforeEach(() => clearAuthState()); +afterEach(() => clearAuthState()); + +describe("LoginPage", () => { + test("shows an unchecked local Remember me control and clears the password after failure", async () => { + let submittedPassword = ""; + server.use(http.post("/api/auth/local/login", async ({ request }) => { + const body = await request.json() as { password: string; remember: boolean }; + submittedPassword = body.password; + expect(body.remember).toBe(false); + return HttpResponse.json({ code: "invalid_credentials" }, { status: 401 }); + })); + + render(); + + expect(screen.getByRole("checkbox", { name: /remember me/i })).not.toBeChecked(); + await userEvent.type(screen.getByLabelText(/username/i), "alice"); + await userEvent.type(screen.getByLabelText(/password/i), "not-a-real-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i); + expect(submittedPassword).toBe("not-a-real-password"); + expect(screen.getByLabelText(/password/i)).toHaveValue(""); + }); + + test("submits a remembered local login once and returns the safe user", async () => { + let requests = 0; + server.use( + http.post("/api/auth/local/login", async ({ request }) => { + requests += 1; + const body = await request.json() as { remember: boolean }; + expect(body.remember).toBe(true); + await delay(20); + return HttpResponse.json({}); + }), + http.get("/api/me", () => HttpResponse.json(authenticated)), + ); + const onAuthenticated = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("checkbox", { name: /remember me/i })); + await userEvent.type(screen.getByLabelText(/username/i), "admin"); + await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + const submit = screen.getByRole("button", { name: /sign in/i }); + await Promise.all([userEvent.click(submit), userEvent.click(submit)]); + + expect(requests).toBe(1); + await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ subject: "admin-1" }))); + expect(screen.getByLabelText(/password/i)).toHaveValue(""); + }); + + test("shows OIDC only when public configuration enables it and uses same-origin navigation", () => { + const { rerender } = render(); + expect(screen.queryByRole("link", { name: /single sign-on/i })).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByRole("link", { name: /single sign-on/i })).toHaveAttribute( + "href", "/api/auth/oidc/login", + ); + }); + + test("does not dispatch local login until an in-flight logout response settles", async () => { + let releaseLogout!: () => void; + let logoutStarted!: () => void; + let loginDispatched = false; + const logoutGate = new Promise((resolve) => { releaseLogout = resolve; }); + const logoutRequest = new Promise((resolve) => { logoutStarted = resolve; }); + server.use( + http.post("/api/auth/logout", async () => { + logoutStarted(); + await logoutGate; + return new HttpResponse(null, { status: 204 }); + }), + http.post("/api/auth/local/login", () => { + loginDispatched = true; + return HttpResponse.json({}); + }), + http.get("/api/me", () => HttpResponse.json(authenticated)), + ); + setAuthState({ ...authenticated, subject: "user-a" }); + const logoutPromise = authApi.logout(); + await logoutRequest; + const loginPromise = authApi.loginLocal("admin", "password", false); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(loginDispatched).toBe(false); + releaseLogout(); + await expect(logoutPromise).resolves.toBe(true); + await loginPromise; + expect(loginDispatched).toBe(true); + }); + + test("a failed logout response does not deadlock the next local login", async () => { + let logoutCalls = 0; + let loginDispatched = false; + server.use( + http.post("/api/auth/logout", () => { + logoutCalls += 1; + return HttpResponse.json({ code: "auth_unavailable" }, { status: 503 }); + }), + http.post("/api/auth/local/login", () => { + loginDispatched = true; + return HttpResponse.json({}); + }), + http.get("/api/me", () => HttpResponse.json(authenticated)), + ); + setAuthState({ ...authenticated, subject: "user-a" }); + await expect(authApi.logout()).rejects.toMatchObject({ status: 503 }); + await authApi.loginLocal("admin", "password", false); + expect(logoutCalls).toBe(1); + expect(loginDispatched).toBe(true); + }); + + test("uses explicit provider-unavailable copy with retry affordance", async () => { + server.use(http.post("/api/auth/local/login", () => HttpResponse.json( + { code: "auth_unavailable" }, { status: 503 }, + ))); + const retry = vi.fn(); + render(); + + await userEvent.type(screen.getByLabelText(/username/i), "alice"); + await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByRole("alert")).toHaveTextContent(/temporarily unavailable/i); + await userEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(retry).toHaveBeenCalledOnce(); + }); + + test("never writes the password, remember choice, or returned token to browser storage", async () => { + server.use( + http.post("/api/auth/local/login", () => HttpResponse.json({})), + http.get("/api/me", () => HttpResponse.json(authenticated)), + ); + const storageWrites = (["setItem", "removeItem", "clear"] as const).map((method) => + vi.spyOn(Storage.prototype, method)); + render(); + await userEvent.type(screen.getByLabelText(/username/i), "alice"); + await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + await vi.waitFor(() => expect(screen.getByLabelText(/password/i)).toHaveValue("")); + for (const write of storageWrites) expect(write).not.toHaveBeenCalled(); + for (const write of storageWrites) write.mockRestore(); + }); + + test("settles a successful login on the current StrictMode mount", async () => { + server.use( + http.post("/api/auth/local/login", () => HttpResponse.json({})), + http.get("/api/me", () => HttpResponse.json(authenticated)), + ); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + const onAuthenticated = vi.fn(); + try { + render(); + await userEvent.type(screen.getByLabelText(/username/i), "admin"); + await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledOnce()); + expect(screen.getByLabelText(/password/i)).toHaveValue(""); + expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled(); + expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i); + } finally { + errors.mockRestore(); + } + }); + + test("settles a failed login and clears the password on the current StrictMode mount", async () => { + server.use(http.post("/api/auth/local/login", () => HttpResponse.json({ code: "invalid_credentials" }, { status: 401 }))); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + render(); + await userEvent.type(screen.getByLabelText(/username/i), "admin"); + await userEvent.type(screen.getByLabelText(/password/i), "wrong-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i); + expect(screen.getByLabelText(/password/i)).toHaveValue(""); + expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled(); + expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i); + } finally { + errors.mockRestore(); + } + }); + + test("does not update an unmounted StrictMode login attempt", async () => { + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + let settled!: () => void; + const settledAfterUnmount = new Promise((resolve) => { settled = resolve; }); + const loginLocal = vi.spyOn(authApi, "loginLocal").mockImplementation(async () => { + try { + await pending; + throw new Error("deferred invalid credentials"); + } finally { + settled(); + } + }); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + const view = render(); + await userEvent.type(screen.getByLabelText(/username/i), "admin"); + await userEvent.type(screen.getByLabelText(/password/i), "wrong-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + view.unmount(); + release(); + await act(async () => { await settledAfterUnmount; }); + expect(loginLocal).toHaveBeenCalledOnce(); + expect(errors.mock.calls.flat().join(" ")).not.toMatch(/can't perform a react state update|not wrapped in act/i); + } finally { + loginLocal.mockRestore(); + errors.mockRestore(); + } + }); +}); diff --git a/frontend/src/auth/LoginPage.tsx b/frontend/src/auth/LoginPage.tsx new file mode 100644 index 00000000..ed81f8bd --- /dev/null +++ b/frontend/src/auth/LoginPage.tsx @@ -0,0 +1,161 @@ +import { useEffect, useRef, useState } from "react"; +import type { FormEvent } from "react"; +import { AlertTriangle, ArrowRight, LockKeyhole } from "lucide-react"; +import { ApiError } from "../api/client"; +import { loginLocal } from "../api/auth"; +import type { AuthenticatedUser, AuthPublicConfig } from "../api/types"; +import { Button } from "../components/ui/button"; + +interface LoginPageProps { + config: AuthPublicConfig; + onAuthenticated: (user: AuthenticatedUser) => void; + onRetry?: () => void; +} + +function loginError(error: unknown): { message: string; retry: boolean } { + if (error instanceof ApiError && error.status === 503) { + return { message: "Authentication is temporarily unavailable. Try again.", retry: true }; + } + if (error instanceof ApiError && error.status === 403) { + return { message: "This sign-in request was rejected. Open ThothII from its configured address and try again.", retry: false }; + } + return { message: "Invalid username or password.", retry: false }; +} + +export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) { + const localLogin = config.mode === "local" && config.localLogin; + const formRef = useRef(null); + const passwordRef = useRef(null); + const mountedRef = useRef(true); + const submittingRef = useRef(false); + const attemptRef = useRef(0); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState<{ message: string; retry: boolean }>(); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (submittingRef.current) return; + const form = event.currentTarget; + const values = new FormData(form); + const username = String(values.get("username") ?? ""); + const password = String(values.get("password") ?? ""); + const remember = values.get("remember") === "on"; + const attempt = ++attemptRef.current; + submittingRef.current = true; + setError(undefined); + setSubmitting(true); + try { + const user = await loginLocal(username, password, remember); + if (mountedRef.current && attemptRef.current === attempt) onAuthenticated(user); + } catch (failure) { + if (mountedRef.current && attemptRef.current === attempt) setError(loginError(failure)); + } finally { + submittingRef.current = false; + if (!mountedRef.current || attemptRef.current !== attempt) return; + // Passwords never enter React state and are cleared after every attempt, + // including operational failures and successful authentication. + form.reset(); + setSubmitting(false); + passwordRef.current?.focus(); + } + } + + return ( +
+
+
+

Secure re-entry · governed workspace

+

+ Return to the analytical ledger. +

+

+ ThothII keeps each question, review decision, and SQL artifact inside a traceable human-in-the-loop workflow. +

+
+
+
+ +
+
+ +

Sign in to ThothII

+

Use your installation account to continue.

+
+ + {error && ( +
+

{error.message}

+ {error.retry && onRetry && ( + + )} +
+ )} + + {localLogin && ( +
+
+ + +
+
+ + +
+ + +
+ )} + + {config.oidcLogin && ( + + Continue with single sign-on + + )} + + {!localLogin && !config.oidcLogin && ( +

+ No browser sign-in method is enabled for this installation. +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/auth/authOperation.test.ts b/frontend/src/auth/authOperation.test.ts new file mode 100644 index 00000000..ccbd92cf --- /dev/null +++ b/frontend/src/auth/authOperation.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, expect, test } from "vitest"; +import { clearAuthState, setAuthState } from "./authState"; +import { captureAuthOperation, isAuthOperationCurrent } from "./authOperation"; + +const userA = { + issuer: "local", subject: "user-a", roles: ["user"] as const, + permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null, +}; + +beforeEach(() => { + clearAuthState(); + setAuthState(userA); +}); + +test("captures identity, generation, session target, and disposal epoch", () => { + const operation = captureAuthOperation({ sessionId: "s1", disposalEpoch: 4 }); + + expect(operation).toMatchObject({ + authGeneration: expect.any(Number), + issuer: "local", + subject: "user-a", + sessionId: "s1", + disposalEpoch: 4, + }); + expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 4 })).toBe(true); +}); + +test("rejects a changed identity, target session, or disposal epoch", () => { + const operation = captureAuthOperation({ sessionId: "s1", disposalEpoch: 4 }); + setAuthState({ ...userA, subject: "user-b", csrfToken: "b".repeat(43) }); + + expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 4 })).toBe(false); + expect(isAuthOperationCurrent(operation!, { sessionId: "s2", disposalEpoch: 4 })).toBe(false); + expect(isAuthOperationCurrent(operation!, { sessionId: "s1", disposalEpoch: 5 })).toBe(false); +}); + +test("does not start an authenticated operation without a principal", () => { + clearAuthState(); + expect(captureAuthOperation()).toBeNull(); +}); diff --git a/frontend/src/auth/authOperation.ts b/frontend/src/auth/authOperation.ts new file mode 100644 index 00000000..28316744 --- /dev/null +++ b/frontend/src/auth/authOperation.ts @@ -0,0 +1,65 @@ +import { getAuthGeneration, getAuthState } from "./authState"; + +export type AuthOperationGuard = Readonly<{ + authGeneration: number; + issuer: string; + subject: string; + sessionId: string | null; + disposalEpoch: number; +}>; + +export type AuthOperationPrecondition = Readonly<{ + operation: AuthOperationGuard; + isCurrent: () => boolean; +}>; + +export class StaleAuthOperationError extends Error { + constructor() { + super("The authenticated operation is no longer current"); + this.name = "StaleAuthOperationError"; + } +} + +export function captureAuthOperation(options: { + sessionId?: string | null; + disposalEpoch?: number; +} = {}): AuthOperationGuard | null { + const user = getAuthState(); + if (!user) return null; + return { + authGeneration: getAuthGeneration(), + issuer: user.issuer, + subject: user.subject, + sessionId: options.sessionId ?? null, + disposalEpoch: options.disposalEpoch ?? 0, + }; +} + +export function isAuthOperationCurrent( + operation: AuthOperationGuard | null, + options: { sessionId?: string | null; disposalEpoch: number }, +): boolean { + if (!operation) return false; + const user = getAuthState(); + return Boolean( + user + && getAuthGeneration() === operation.authGeneration + && user.issuer === operation.issuer + && user.subject === operation.subject + && operation.sessionId === (options.sessionId ?? null) + && operation.disposalEpoch === options.disposalEpoch, + ); +} + +export function requireCurrentAuthOperation( + operation: AuthOperationGuard | null, + options: { sessionId?: string | null; disposalEpoch: number }, +): asserts operation is AuthOperationGuard { + if (!isAuthOperationCurrent(operation, options)) throw new StaleAuthOperationError(); +} + +export function requireAuthOperationPrecondition( + precondition: AuthOperationPrecondition | undefined, +): void { + if (precondition && !precondition.isCurrent()) throw new StaleAuthOperationError(); +} diff --git a/frontend/src/auth/authState.test.ts b/frontend/src/auth/authState.test.ts new file mode 100644 index 00000000..b4437cc6 --- /dev/null +++ b/frontend/src/auth/authState.test.ts @@ -0,0 +1,57 @@ +import { http, HttpResponse } from "msw"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { apiFetch } from "../api/client"; +import { queryClient } from "../app/queryClient"; +import { server } from "../test/msw"; +import { useSessionStore } from "../store/sessionStore"; +import { clearAuthState, getAuthState, setAuthState } from "./authState"; + +const userA = { + issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false, + csrfToken: "a".repeat(43), session: null, +}; +const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) }; + +beforeEach(() => { + queryClient.clear(); + useSessionStore.getState().resetSession(); + clearAuthState(); +}); + +afterEach(() => { + queryClient.clear(); + useSessionStore.getState().resetSession(); + clearAuthState(); +}); + +function seedUserAData() { + setAuthState(userA); + queryClient.setQueryData(["sessions", "mine"], [{ id: "a-session" }]); + useSessionStore.getState().applyEvent({ type: "text_delta", text: "A transcript" }); +} + +test("an ordinary API 401 scrubs A data before B can log in", async () => { + server.use(http.get("/api/ordinary-expiry", () => new HttpResponse(null, { status: 401 }))); + seedUserAData(); + + await expect(apiFetch("/ordinary-expiry")).rejects.toMatchObject({ status: 401 }); + expect(getAuthState()).toBeNull(); + expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined(); + expect(useSessionStore.getState().transcript).toEqual([]); + + setAuthState(userB); + expect(getAuthState()).toMatchObject({ subject: "user-b" }); + expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined(); + expect(useSessionStore.getState().transcript).toEqual([]); +}); + +test("logout followed by B login cannot retain A cache or live transcript", () => { + seedUserAData(); + + clearAuthState(); + setAuthState(userB); + + expect(getAuthState()).toMatchObject({ subject: "user-b" }); + expect(queryClient.getQueryData(["sessions", "mine"])).toBeUndefined(); + expect(useSessionStore.getState().transcript).toEqual([]); +}); diff --git a/frontend/src/auth/authState.ts b/frontend/src/auth/authState.ts new file mode 100644 index 00000000..fb46a65e --- /dev/null +++ b/frontend/src/auth/authState.ts @@ -0,0 +1,71 @@ +import { useSyncExternalStore } from "react"; +import type { AuthenticatedUser } from "../api/types"; +import { queryClient } from "../app/queryClient"; +import { useSessionStore } from "../store/sessionStore"; + +let current: AuthenticatedUser | null = null; +let generation = 0; +const listeners = new Set<() => void>(); + +function notify() { + for (const listener of listeners) listener(); +} + +function scrubUserBoundState(): void { + queryClient.clear(); + useSessionStore.getState().resetSession(); +} + +/** Authentication is intentionally process-local; no browser storage is involved. */ +export function getAuthState(): AuthenticatedUser | null { + return current; +} + +export function setAuthState(user: AuthenticatedUser): void { + scrubUserBoundState(); + current = user; + generation += 1; + notify(); +} + +export function clearAuthState(): void { + scrubUserBoundState(); + current = null; + generation += 1; + notify(); +} + +export function isAuthGenerationCurrent(expectedGeneration: number): boolean { + return generation === expectedGeneration; +} + +export function clearAuthStateIfCurrent(expectedGeneration: number): boolean { + if (!isAuthGenerationCurrent(expectedGeneration)) return false; + clearAuthState(); + return true; +} + +export function getAuthGeneration(): number { + return generation; +} + +export function subscribeAuthState(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useAuthState(): AuthenticatedUser | null { + return useSyncExternalStore(subscribeAuthState, getAuthState, getAuthState); +} + +export function useAuthUser(): AuthenticatedUser | null { + return useAuthState(); +} + +export function useAuthGeneration(): number { + return useSyncExternalStore(subscribeAuthState, getAuthGeneration, getAuthGeneration); +} + +export function hasPermission(user: Pick | null | undefined, permission: string): boolean { + return user?.permissions.includes(permission) ?? false; +} diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx new file mode 100644 index 00000000..3a13d381 --- /dev/null +++ b/frontend/src/shell/AppShell.auth.test.tsx @@ -0,0 +1,210 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AppShell } from "./AppShell"; +import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState"; +import { server } from "../test/msw"; +import { useSessionStore } from "../store/sessionStore"; + +function renderShell(user: { + subject: string; + isAdmin: boolean; + roles: readonly ("user" | "admin")[]; + permissions: readonly string[]; +}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + setAuthState({ + issuer: "local", ...user, csrfToken: null, session: null, + }); + return render(); +} + +function KeyedAuthenticatedShell() { + const user = useAuthUser(); + const generation = useAuthGeneration(); + return user ? : null; +} + +beforeEach(() => { + clearAuthState(); + useSessionStore.getState().resetSession(); + server.use( + http.get("/api/sessions", () => HttpResponse.json([])), + http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })), + http.get("/api/workspaces", () => HttpResponse.json([])), + http.get("/api/workspace-registry/status", () => HttpResponse.json({ + branch: "main", ahead: 0, behind: 0, degraded: false, + })), + http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.get("/api/health/dwh", () => HttpResponse.json({ ok: true })), + ); +}); + +describe("authenticated shell permissions", () => { + test("shows only read-safe workspace chrome to a session user", async () => { + renderShell({ subject: "user-1", isAdmin: false, roles: ["user"], permissions: ["session.use"] }); + + expect(await screen.findByRole("button", { name: "Workspace management" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument(); + }); + + test("shows management and all-session chrome only for exact permissions", async () => { + renderShell({ + subject: "admin-1", + isAdmin: true, + roles: ["admin"], + permissions: ["session.use", "session.read_all", "workspace.manage", "workspace.secrets.manage", "pi.manage"], + }); + + expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument(); + }); + + test("logout revokes the cookie session and clears in-memory auth", async () => { + const user = { + issuer: "local" as const, + subject: "admin-1", + roles: ["admin"] as const, + permissions: ["session.use", "pi.manage"], + isAdmin: true, + csrfToken: "c".repeat(43), + session: null, + }; + setAuthState(user); + let logoutCalls = 0; + server.use(http.post("/api/auth/logout", () => { + logoutCalls += 1; + return new HttpResponse(null, { status: 204 }); + })); + renderShell(user); + + await userEvent.click(screen.getByRole("button", { name: "Log out" })); + + await vi.waitFor(() => expect(logoutCalls).toBe(1)); + expect(getAuthState()).toBeNull(); + }); + + test("a stale logout continuation cannot scrub user B after the logout response settles", async () => { + const userA = { + issuer: "local" as const, subject: "user-a", roles: ["user"] as const, + permissions: ["session.use"] as const, isAdmin: false, + csrfToken: "a".repeat(43), session: null, + }; + const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) }; + let releaseLogout!: () => void; + let logoutStarted!: () => void; + let logoutSettled!: () => void; + const logoutGate = new Promise((resolve) => { releaseLogout = resolve; }); + const started = new Promise((resolve) => { logoutStarted = resolve; }); + const settled = new Promise((resolve) => { logoutSettled = resolve; }); + server.use(http.post("/api/auth/logout", async () => { + logoutStarted(); + try { + await logoutGate; + return new HttpResponse(null, { status: 204 }); + } finally { + logoutSettled(); + } + })); + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + setAuthState(userA); + render(); + await userEvent.click(screen.getByRole("button", { name: "Log out" })); + await started; + + act(() => setAuthState(userB)); + act(() => { + client.setQueryData(["b-only"], { owner: "user-b" }); + useSessionStore.getState().applyEvent({ type: "text_delta", text: "B transcript" }); + }); + releaseLogout(); + await act(async () => { await settled; }); + + expect(getAuthState()).toMatchObject({ subject: "user-b" }); + expect(client.getQueryData(["b-only"])).toEqual({ owner: "user-b" }); + expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B transcript" }]); + expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument(); + }); + + test("handles a failed shell logout without an unhandled rejection", async () => { + const user = { + issuer: "local" as const, subject: "user-a", roles: ["user"] as const, + permissions: ["session.use"] as const, isAdmin: false, + csrfToken: "a".repeat(43), session: null, + }; + const rejection = vi.fn(); + process.on("unhandledRejection", rejection); + server.use(http.post("/api/auth/logout", () => HttpResponse.json( + { code: "auth_unavailable" }, { status: 503 }, + ))); + try { + renderShell(user); + await userEvent.click(screen.getByRole("button", { name: "Log out" })); + await waitFor(() => expect(getAuthState()).toBeNull()); + await new Promise((resolve) => setImmediate(resolve)); + expect(rejection).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", rejection); + } + }); + + test("permission chrome follows current auth state after a stale admin identity disappears", async () => { + renderShell({ + subject: "admin-1", isAdmin: true, roles: ["admin"], + permissions: ["session.use", "session.read_all", "workspace.manage", "pi.manage"], + }); + expect(await screen.findByRole("button", { name: "Pi management" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument(); + + act(() => clearAuthState()); + expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Workspace management" })); + expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Update workspace repository" })).not.toBeInTheDocument(); + }); + + test("remounts the real shell so user-A panel and transcript state cannot survive user-B", async () => { + const userA = { + issuer: "local" as const, + subject: "user-a", + roles: ["user"] as const, + permissions: ["session.use"] as const, + isAdmin: false, + }; + const userB = { ...userA, subject: "user-b" }; + const panelSession = { + id: "panel-a", status: "finalized", question: "User A governed question", summary: null, + created_at: "2026-08-17T10:00:00Z", updated_at: null, author: "user-a", name: null, + group: null, archived: false, + }; + server.use( + http.get("/api/sessions", () => HttpResponse.json([panelSession])), + http.get("/api/sessions/panel-a/documents", () => HttpResponse.json([ + { key: "question", title: "Question", phase: "F1", format: "markdown", content: "A-private-document" }, + ])), + ); + setAuthState({ ...userA, csrfToken: null, session: null }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + function renderUserShell() { + return render(); + } + renderUserShell(); + + await userEvent.click(await screen.findByTestId("session-item-panel-a")); + expect(await screen.findByText("A-private-document")).toBeInTheDocument(); + act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "A-private-transcript" })); + expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "A-private-transcript" }]); + + act(() => setAuthState({ ...userB, csrfToken: null, session: null })); + + await waitFor(() => expect(screen.queryByText("A-private-document")).not.toBeInTheDocument()); + expect(screen.queryByRole("heading", { name: "User A governed question" })).not.toBeInTheDocument(); + expect(useSessionStore.getState().transcript).toEqual([]); + expect(getAuthGeneration()).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index 737c91a1..e570ffd4 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -6,6 +6,7 @@ import { server } from "../test/msw"; import { FakeEventSource } from "../test/fakeEventSource"; import { useSessionStore } from "../store/sessionStore"; import { AppShell } from "./AppShell"; +import { clearAuthState, setAuthState } from "../auth/authState"; import { workspacePreferences } from "../workspaces/preferences"; function renderShell() { @@ -14,6 +15,11 @@ function renderShell() { } beforeEach(() => { + clearAuthState(); + setAuthState({ + issuer: "test", subject: "test", roles: ["user"], permissions: ["session.use"], + isAdmin: false, csrfToken: null, session: null, + }); localStorage.clear(); FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; diff --git a/frontend/src/shell/AppShell.notifications.test.tsx b/frontend/src/shell/AppShell.notifications.test.tsx index 4555c9ab..9b1e9e74 100644 --- a/frontend/src/shell/AppShell.notifications.test.tsx +++ b/frontend/src/shell/AppShell.notifications.test.tsx @@ -18,6 +18,7 @@ beforeEach(() => { }); test("session errors queued in the store become visible notifications", async () => { render(); + await screen.findByTestId("app-shell"); act(() => useSessionStore.getState().pushToast({ level: "error", diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 16cd5fff..3b2b3550 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -5,10 +5,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; import { FakeEventSource } from "../test/fakeEventSource"; import { AppShell } from "./AppShell"; +import type { AuthenticatedUser } from "../api/types"; +import { clearAuthState, setAuthState } from "../auth/authState"; import { useSessionStore } from "../store/sessionStore"; import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize"; -function wrap() { +const regularUser: AuthenticatedUser = { + issuer: "portal", subject: "alice", displayName: "Alice", roles: ["user"] as const, + permissions: ["session.use", "pi.manage"] as const, isAdmin: false, csrfToken: null, session: null, +}; +const adminUser: AuthenticatedUser = { + ...regularUser, subject: "alice-id", roles: ["admin"] as const, + permissions: ["session.use", "session.read_all", "pi.manage"] as const, isAdmin: true, +}; + +function wrap(user: AuthenticatedUser = regularUser) { + if (user !== regularUser) setAuthState(user); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render(); } @@ -47,6 +59,8 @@ function deferred() { } beforeEach(() => { + clearAuthState(); + setAuthState(regularUser); localStorage.clear(); FakeEventSource.instances = []; ControlledResizeObserver.instances = []; @@ -57,13 +71,18 @@ beforeEach(() => { useSessionStore.getState().resetSession(); server.use( http.get("/api/me", () => - HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }), + HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false, permissions: ["session.use", "pi.manage"] }), ), + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })), + http.get("/api/workspaces", () => HttpResponse.json([])), + http.get("/api/models", () => HttpResponse.json({ models: [] })), http.get("/api/sessions", () => HttpResponse.json(LIST)), + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), http.get("/api/sessions/:id/documents", () => HttpResponse.json([ { phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" }, ])), http.post("/api/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })), + http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })), ); }); @@ -135,7 +154,7 @@ test("administrators can explicitly switch to all sessions and see owners", asyn ]); }), ); - wrap(); + wrap(adminUser); await screen.findByRole("button", { name: "All sessions" }); expect(screen.getByRole("button", { name: "My sessions" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "All sessions" })).toHaveAttribute("aria-pressed", "false"); @@ -162,7 +181,7 @@ test("administrator confirms before deleting a same-named user's session", async return new HttpResponse(null, { status: 204 }); }), ); - wrap(); + wrap(adminUser); await userEvent.click(await screen.findByRole("button", { name: "All sessions" })); await screen.findByText("Owner: Alice"); await userEvent.click(screen.getByRole("checkbox", { name: "Select Attiva uno" })); @@ -188,7 +207,7 @@ test("administrator confirms before archiving a same-named user's session", asyn return new HttpResponse(null, { status: 204 }); }), ); - wrap(); + wrap(adminUser); await userEvent.click(await screen.findByRole("button", { name: "All sessions" })); await screen.findByText("Owner: Alice"); await userEvent.click(screen.getByRole("button", { name: "Session actions" })); @@ -735,6 +754,63 @@ test("starting a new question invalidates a pending Resume intent", async () => expect(screen.getByText(/type your question/i)).toBeInTheDocument(); }); +test("a delayed Resume from user A cannot activate a session after user B logs in", async () => { + const resumeGate = deferred(); + const resumeStarted = deferred(); + const resumeSettled = deferred(); + server.use( + http.post("/api/sessions/:id/resume", async () => { + resumeStarted.resolve(); + try { + await resumeGate.promise; + return resumeResult("s1"); + } finally { + resumeSettled.resolve(); + } + }), + http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 4 })), + ); + wrap(); + + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); + await resumeStarted.promise; + act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" })); + resumeGate.resolve(); + await act(async () => { await resumeSettled.promise; }); + expect(FakeEventSource.instances).toHaveLength(0); + expect(useSessionStore.getState().currentPhase).toBeNull(); + expect(screen.queryByRole("button", { name: "Hide model activity" })).not.toBeInTheDocument(); +}); + +test("a delayed session deletion from user A cannot refresh or toast into user B", async () => { + const deleteGate = deferred(); + const deleteStarted = deferred(); + const deleteSettled = deferred(); + server.use(http.delete("/api/sessions/:id", async () => { + deleteStarted.resolve(); + try { + await deleteGate.promise; + return new HttpResponse(null, { status: 204 }); + } finally { + deleteSettled.resolve(); + } + })); + wrap(); + + await screen.findByText("Attiva uno"); + screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus(); + await userEvent.keyboard(" "); + await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" })); + await deleteStarted.promise; + act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" })); + deleteGate.resolve(); + await act(async () => { await deleteSettled.promise; }); + expect(useSessionStore.getState().toasts).toEqual([]); + expect(screen.queryByText("Deleted 1 of 1 sessions.")).not.toBeInTheDocument(); + expect(screen.getByTestId("session-item-s1")).toBeInTheDocument(); +}); + test("a successful Delete invalidates an earlier pending Resume for the same target", async () => { const resumeGate = deferred(); const resumeStarted = deferred(); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index ecc7ab8a..889ffabc 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -21,19 +21,23 @@ import { Checkbox } from "../components/ui/checkbox"; import { Toaster } from "../components/ui/sonner"; import { toast } from "sonner"; import { - closeSession, getMe, listSessions, resumeSession, getSession, + closeSession, listSessions, resumeSession, getSession, renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime, checkDwhHealth, } from "../api/sessions"; +import { logout as logoutUser } from "../api/auth"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; -import type { Principal, SessionScope, SessionSummary } from "../api/types"; +import type { SessionScope, SessionSummary } from "../api/types"; +import { useAuthGeneration, useAuthUser } from "../auth/authState"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; +import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation"; export function AppShell() { + const authenticatedUser = useAuthUser(); const [panelSession, setPanelSession] = useState(null); const { containerRef, @@ -81,14 +85,29 @@ export function AppShell() { const resumeInFlightRef = useRef(new Map; + guard: AuthOperationGuard; }>()); const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0); const [creatingSession, setCreatingSession] = useState(false); const [awaitingQuestion, setAwaitingQuestion] = useState(false); const [sessionScope, setSessionScope] = useState("mine"); - const { data: principal } = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: Infinity }); + const principal = authenticatedUser; + const permissions = authenticatedUser?.permissions ?? []; + const canReadAllSessions = permissions.includes("session.read_all"); + const canManageWorkspace = permissions.includes("workspace.manage"); + const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage"); + const canManagePi = permissions.includes("pi.manage"); + const authGeneration = useAuthGeneration(); const { data: sessions = [] } = useQuery({ - queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000, + queryKey: ["sessions", sessionScope], + queryFn: async () => { + const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current }); + if (!guard) throw new StaleAuthOperationError(); + const result = await listSessions(sessionScope); + if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) throw new StaleAuthOperationError(); + return result; + }, + refetchInterval: 10_000, }); const composerRef = useRef(null); @@ -107,11 +126,15 @@ export function AppShell() { const [dwhDown, setDwhDown] = useState(false); const [dwhChecking, setDwhChecking] = useState(true); const [dwhCheckEpoch, setDwhCheckEpoch] = useState(0); + const operationEpochRef = useRef(0); + useEffect(() => () => { operationEpochRef.current += 1; }, []); useEffect(() => { let cancelled = false; + const operation = captureAuthOperation({ disposalEpoch: operationEpochRef.current }); + if (!operation) return () => { cancelled = true; }; setDwhChecking(true); checkDwhHealth().then((r) => { - if (cancelled) return; + if (cancelled || !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return; setDwhDown(!r.ok); setDwhChecking(false); }); @@ -125,7 +148,10 @@ export function AppShell() { const activeList = sessions.filter((s) => !s.archived); const ungroupedActive = activeList.filter((s) => !s.group); const archivedList = sessions.filter((s) => s.archived); - const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] }); + const refresh = (operation?: AuthOperationGuard | null) => { + if (operation && !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return; + void queryClient.invalidateQueries({ queryKey: ["sessions"] }); + }; const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null; const finalized = activeSession?.status === "finalized"; const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id)); @@ -158,6 +184,10 @@ export function AppShell() { }); }, [sessions]); + useEffect(() => { + if (!canReadAllSessions && sessionScope === "all") setSessionScope("mine"); + }, [canReadAllSessions, sessionScope]); + function setSessionSelected(id: string, selected: boolean) { setSelectedSessionIds((current) => { const next = new Set(current); @@ -193,10 +223,12 @@ export function AppShell() { }); } async function doResume(id: string) { + const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current }); + if (!guard) return; const token = ++resumeInvocationRef.current; latestResumeIntentRef.current = { token, id }; const inFlight = resumeInFlightRef.current.get(id); - if (inFlight) { + if (inFlight && isAuthOperationCurrent(inFlight.guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) { // Repeated intent for the same target shares one backend lifecycle operation and one // commit path. Updating its token still lets s1→s2→s1 make the final s1 intent authoritative. inFlight.latestToken = token; @@ -206,8 +238,9 @@ export function AppShell() { const operation = { latestToken: token, promise: Promise.resolve(), + guard, }; - operation.promise = runResume(id, operation).finally(() => { + operation.promise = runResume(id, operation, guard).finally(() => { if (resumeInFlightRef.current.get(id) === operation) { resumeInFlightRef.current.delete(id); } @@ -219,9 +252,11 @@ export function AppShell() { async function runResume( id: string, operation: { latestToken: number; promise: Promise }, + guard: AuthOperationGuard, ) { try { const result = await resumeSession(id); + if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; const latest = latestResumeIntentRef.current; if (latest?.token !== operation.latestToken || latest.id !== id) return; const reconnectSameSession = activeSessionIdRef.current === id; @@ -254,6 +289,7 @@ export function AppShell() { // The manifest's `phase` is the 1-based current phase (1..8). try { const m = (await getSession(id)) as { phase?: number }; + if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; const latestAfterManifest = latestResumeIntentRef.current; if ( latestAfterManifest?.token !== operation.latestToken @@ -267,7 +303,7 @@ export function AppShell() { /* non-fatal: the first gate will set the phase */ } } catch { - if ( + if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current }) && latestResumeIntentRef.current?.token === operation.latestToken && latestResumeIntentRef.current.id === id ) { @@ -276,41 +312,73 @@ export function AppShell() { } } async function move(s: SessionSummary, group: string) { + const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current }); + if (!guard) return; try { - await setSessionGroup(s.id, group); refresh(); + await setSessionGroup(s.id, group); + if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return; + refresh(guard); } catch { - toast.error("Failed to move session."); + if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to move session."); } } async function newGroup(s: SessionSummary) { const name = window.prompt("New group:"); if (name && name.trim()) { + const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current }); + if (!guard) return; try { - await setSessionGroup(s.id, name.trim()); refresh(); + await setSessionGroup(s.id, name.trim()); + if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return; + refresh(guard); } catch { - toast.error("Failed to update group."); + if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to update group."); } } } - async function renameGroup(oldName: string, newName: string) { + async function renameGroup(oldName: string, newName: string, suppliedGuard?: AuthOperationGuard | null) { const trimmed = newName.trim(); - if (!trimmed || trimmed === oldName) return; + if (!trimmed || trimmed === oldName) return false; + const guard = suppliedGuard ?? captureAuthOperation({ disposalEpoch: operationEpochRef.current }); + if (!guard) return false; try { for (const s of sessions.filter((x) => x.group === oldName)) { await setSessionGroup(s.id, trimmed); + if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return false; } - refresh(); + refresh(guard); + return true; } catch { - toast.error("Failed to rename group."); + if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to rename group."); + return false; + } + } + async function renameSessionFromDialog(id: string, name: string) { + const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current }); + if (!guard) return; + try { + await renameSession(id, name); + if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; + setRenameTarget(null); + refresh(guard); + } catch { + if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) { + toast.error("Failed to rename session."); + } } } async function toggleArchive(s: SessionSummary) { + const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current }); + if (!guard) return; try { await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id)); + if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return; if (panelSession?.id === s.id) setPanelSession(null); - refresh(); + refresh(guard); } catch { - toast.error(s.archived ? "Failed to restore session." : "Failed to archive session."); + if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) { + toast.error(s.archived ? "Failed to restore session." : "Failed to archive session."); + } } } @@ -323,8 +391,11 @@ export function AppShell() { } async function deleteSessions(targets: SessionSummary[]) { + const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current }); + if (!guard) return; try { const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id))); + if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return; const deletedIds = new Set( targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id), ); @@ -334,12 +405,12 @@ export function AppShell() { if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null); if (deletedActiveSession) { resetSession(); selectActiveSession(null); } setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id)))); - refresh(); + refresh(guard); if (deletedIds.size !== targets.length) { toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`); } } catch { - toast.error("Failed to delete selected sessions."); + if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to delete selected sessions."); } } @@ -401,7 +472,13 @@ export function AppShell() { // session sits idle or a gate awaits the reviewer (pendingWidget). const running = working && !finalized; - useSessionStream(activeSessionId, 0, streamCursorResetEpoch); + useSessionStream( + activeSessionId, + 0, + streamCursorResetEpoch, + Boolean(authenticatedUser), + authGeneration, + ); // A backend "session_exit" system event (e.g. the replay server emitting it // when the reviewer picks "Esci") asks us to leave the live session view and @@ -463,16 +540,24 @@ export function AppShell() { async function stopSession() { if (!activeSessionId) return; + const id = activeSessionId; + const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current }); + if (!guard) return; invalidateResumeIntent(); try { - await closeSession(activeSessionId); + await closeSession(id); } finally { + if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; resetSession(); selectActiveSession(null); setAwaitingQuestion(false); } } + async function signOut() { + await logoutUser(); + } + return (
Human In The Loop

+ {authenticatedUser && ( +
+ + {authenticatedUser.displayName ?? authenticatedUser.subject} + + +
+ )}
@@ -630,17 +723,19 @@ export function AppShell() { > Workspace management - + {canManagePi && ( + + )}
- {principal?.isAdmin && ( + {canReadAllSessions && (
-
+ } + {!canManageWorkspace && ( +

+ You can inspect workspaces. Workspace updates, validation, and connection tests require workspace management permission. +

+ )}

Select a workspace from the left only for workspace-specific validation, runtime credentials, and connection tests.

) : ( @@ -387,9 +453,9 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () ))} )} - + }

Test workspace connections

@@ -408,13 +474,13 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () ))}
)} - + } -
+ {canManageSecrets &&
@@ -478,7 +544,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
)} -
+
} + {!canManageSecrets && ( +

+ Runtime secret controls are available only to authorized workspace operators. +

+ )} )} diff --git a/frontend/src/shell/f1-loop.test.tsx b/frontend/src/shell/f1-loop.test.tsx index 4b3b43e3..27b7d8dd 100644 --- a/frontend/src/shell/f1-loop.test.tsx +++ b/frontend/src/shell/f1-loop.test.tsx @@ -33,7 +33,7 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async render(); // Type the first question directly in the sticky composer and send it - const composer = screen.getByRole("textbox", { name: /new question/i }); + const composer = await screen.findByRole("textbox", { name: /new question/i }); await userEvent.type(composer, "Quante vendite?"); await userEvent.click(screen.getByRole("button", { name: /send/i })); diff --git a/frontend/src/stream/useSessionStream.test.tsx b/frontend/src/stream/useSessionStream.test.tsx index 70b604eb..da698f2a 100644 --- a/frontend/src/stream/useSessionStream.test.tsx +++ b/frontend/src/stream/useSessionStream.test.tsx @@ -1,13 +1,17 @@ -import { renderHook } from "@testing-library/react"; +import { renderHook, waitFor } from "@testing-library/react"; import { act, useLayoutEffect } from "react"; import { FakeEventSource } from "../test/fakeEventSource"; import { createStreamEventCoalescer, useSessionStream } from "./useSessionStream"; import { useSessionStore } from "../store/sessionStore"; +import { clearAuthState, getAuthGeneration, getAuthState, setAuthState } from "../auth/authState"; +import { http, HttpResponse } from "msw"; +import { server } from "../test/msw"; beforeEach(() => { FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; useSessionStore.getState().resetSession(); + clearAuthState(); }); test("coalesces a burst of text deltas into bounded store updates", () => { @@ -32,6 +36,73 @@ test("coalesces a burst of text deltas into bounded store updates", () => { } }); +test("disposal discards buffered deltas instead of flushing them", () => { + vi.useFakeTimers(); + try { + const applied: Array<{ type: string; text?: string }> = []; + const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100); + + coalescer.push({ type: "text_delta", text: "a" }); + coalescer.push({ type: "text_delta", text: "b" }); + coalescer.dispose(); + vi.advanceTimersByTime(100); + + expect(applied).toEqual([{ type: "text_delta", text: "a" }]); + } finally { + vi.useRealTimers(); + } +}); + +test("a stale coalescer timer checks source validity before applying text", () => { + vi.useFakeTimers(); + try { + let current = true; + const applied: Array<{ type: string; text?: string }> = []; + const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100, () => current); + + coalescer.push({ type: "text_delta", text: "a" }); + coalescer.push({ type: "text_delta", text: "b" }); + current = false; + vi.advanceTimersByTime(100); + + expect(applied).toEqual([{ type: "text_delta", text: "a" }]); + coalescer.dispose(); + } finally { + vi.useRealTimers(); + } +}); + +test("buffered user-A text cannot reappear after logout and user-B login", () => { + vi.useFakeTimers(); + try { + const userA = { + issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false, + csrfToken: "a".repeat(43), session: null, + }; + const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) }; + server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 }))); + setAuthState(userA); + const { rerender } = renderHook( + ({ authGeneration }) => useSessionStream("s1", 0, 0, true, authGeneration), + { initialProps: { authGeneration: getAuthGeneration() } }, + ); + const sourceA = FakeEventSource.instances[0]; + act(() => { + sourceA.emit({ type: "text_delta", text: "A-one" }); + sourceA.emit({ type: "text_delta", text: "A-two" }); + sourceA.onerror?.(new Event("error")); + setAuthState(userB); + }); + rerender({ authGeneration: getAuthGeneration() }); + vi.advanceTimersByTime(1000); + + expect(useSessionStore.getState().transcript).toEqual([]); + expect(FakeEventSource.instances[0].closed).toBe(true); + } finally { + vi.useRealTimers(); + } +}); + test("flushes pending stream text before a structural event", () => { vi.useFakeTimers(); try { @@ -280,3 +351,130 @@ test("the old source is invalid before later layout effects can deliver a queued expect(first.closed).toBe(true); expect(useSessionStore.getState().transcript).toEqual([]); }); + +test("auth-generation changes close the old source, reset live state, and do not reconnect after logout", () => { + setAuthState({ + issuer: "local", subject: "user-1", roles: ["user"], permissions: ["session.use"], isAdmin: false, + csrfToken: "c".repeat(43), session: null, + }); + const { rerender } = renderHook( + ({ authenticated, authGeneration }) => useSessionStream("s1", 0, 0, authenticated, authGeneration), + { initialProps: { authenticated: true, authGeneration: getAuthGeneration() } }, + ); + const first = FakeEventSource.instances[0]; + act(() => first.emitNamed("text_delta", { type: "text_delta", text: "stale" })); + expect(useSessionStore.getState().transcript).toHaveLength(1); + + clearAuthState(); + rerender({ authenticated: false, authGeneration: getAuthGeneration() }); + + expect(first.closed).toBe(true); + expect(FakeEventSource.instances).toHaveLength(1); + expect(useSessionStore.getState().transcript).toEqual([]); +}); + +test("a stale source error cannot clear or mutate the next user's live session", async () => { + const userA = { + issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false, + csrfToken: "a".repeat(43), session: null, + }; + const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) }; + setAuthState(userA); + const { rerender } = renderHook( + ({ authenticated, authGeneration }) => useSessionStream("s1", 0, 0, authenticated, authGeneration), + { initialProps: { authenticated: true, authGeneration: getAuthGeneration() } }, + ); + const sourceA = FakeEventSource.instances[0]; + + act(() => setAuthState(userB)); + rerender({ authenticated: true, authGeneration: getAuthGeneration() }); + const sourceB = FakeEventSource.instances[1]; + act(() => sourceB.emitNamed("text_delta", { type: "text_delta", text: "B-data" })); + + act(() => sourceA.onerror?.(new Event("error"))); + + expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" }); + expect(sourceB.closed).toBe(false); + expect(FakeEventSource.instances).toHaveLength(2); + expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B-data" }]); +}); + +test("a native current-source error probes /me once and clears auth on a guarded 401", async () => { + let probes = 0; + server.use(http.get("/api/me", () => { + probes += 1; + return new HttpResponse(null, { status: 401 }); + })); + setAuthState({ + issuer: "local", subject: "user-a", roles: ["user"], permissions: ["session.use"], isAdmin: false, + csrfToken: "a".repeat(43), session: null, + }); + renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration())); + const source = FakeEventSource.instances[0]; + + act(() => { + source.onerror?.(new Event("error")); + source.onerror?.(new Event("error")); + }); + await waitFor(() => expect(getAuthState()).toBeNull()); + + expect(probes).toBe(1); + expect((await import("../auth/authState")).getAuthState()).toBeNull(); +}); + +test("a native error keeps current auth connected after a 200 /me probe and does not storm", async () => { + let probes = 0; + const user = { + issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false, + csrfToken: "a".repeat(43), session: null, + }; + server.use(http.get("/api/me", () => { + probes += 1; + return HttpResponse.json(user); + })); + setAuthState(user); + const result = renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration())); + const source = FakeEventSource.instances[0]; + + act(() => source.onerror?.(new Event("error"))); + await waitFor(() => expect(result.result.current.connected).toBe(true)); + act(() => source.onerror?.(new Event("error"))); + + expect(probes).toBe(1); + expect(getAuthState()).toMatchObject({ subject: "user-a" }); +}); + +test("a stale source does not probe and a user-B login during an A probe cannot mutate B", async () => { + let release!: () => void; + let probes = 0; + const held = new Promise((resolve) => { release = resolve; }); + const userA = { + issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false, + csrfToken: "a".repeat(43), session: null, + }; + const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) }; + server.use(http.get("/api/me", async () => { + probes += 1; + await held; + return HttpResponse.json(userA); + })); + setAuthState(userA); + const { rerender } = renderHook( + ({ sessionId, authGeneration }) => useSessionStream(sessionId, 0, 0, true, authGeneration), + { initialProps: { sessionId: "s1" as string | null, authGeneration: getAuthGeneration() } }, + ); + const sourceA = FakeEventSource.instances[0]; + act(() => sourceA.onerror?.(new Event("error"))); + await waitFor(() => expect(probes).toBe(1)); + + act(() => setAuthState(userB)); + rerender({ sessionId: "s2", authGeneration: getAuthGeneration() }); + const sourceB = FakeEventSource.instances[1]; + act(() => sourceA.onerror?.(new Event("error"))); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(probes).toBe(1); + expect(getAuthState()).toMatchObject({ subject: "user-b" }); + expect(sourceB.closed).toBe(false); +}); diff --git a/frontend/src/stream/useSessionStream.ts b/frontend/src/stream/useSessionStream.ts index 62f28ccd..c138abc2 100644 --- a/frontend/src/stream/useSessionStream.ts +++ b/frontend/src/stream/useSessionStream.ts @@ -3,6 +3,12 @@ import { BASE } from "../api/client"; import { joinBackendPath } from "../api/runtime-config"; import { useSessionStore } from "../store/sessionStore"; import type { StreamEvent } from "../api/types"; +import { + getAuthGeneration, + isAuthGenerationCurrent, +} from "../auth/authState"; +import { getMe } from "../api/auth"; +import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation"; const STREAM_UPDATE_INTERVAL_MS = 100; @@ -15,6 +21,7 @@ function isStreamingDelta( export function createStreamEventCoalescer( applyEvent: (event: StreamEvent) => void, intervalMs = STREAM_UPDATE_INTERVAL_MS, + canApply: () => boolean = () => true, ) { let pending: Extract | null = null; let timer: ReturnType | null = null; @@ -24,6 +31,7 @@ export function createStreamEventCoalescer( if (!pending) return; const event = pending; pending = null; + if (disposed || !canApply()) return; applyEvent(event); }; @@ -31,14 +39,18 @@ export function createStreamEventCoalescer( timer = setTimeout(() => { timer = null; if (!pending || disposed) return; + if (!canApply()) { + pending = null; + return; + } flushPending(); - schedule(); + if (pending && !disposed && canApply()) schedule(); }, intervalMs); }; return { push(event: StreamEvent) { - if (disposed) return; + if (disposed || !canApply()) return; if (!isStreamingDelta(event)) { flushPending(); if (timer) clearTimeout(timer); @@ -60,7 +72,7 @@ export function createStreamEventCoalescer( if (disposed) return; if (timer) clearTimeout(timer); timer = null; - flushPending(); + pending = null; disposed = true; }, }; @@ -70,24 +82,33 @@ export function useSessionStream( sessionId: string | null, generation = 0, cursorResetEpoch = 0, + authenticated = true, + authGeneration?: number, ) { + const effectiveAuthGeneration = authGeneration ?? getAuthGeneration(); const [connected, setConnected] = useState(false); const applyEvent = useSessionStore((s) => s.applyEvent); + const resetSession = useSessionStore((s) => s.resetSession); const cursor = useRef({ sessionId: null as string | null, cursorResetEpoch, + authGeneration: null as number | null, lastEventId: "", }); const activeSource = useRef(null); useLayoutEffect(() => { + const authChanged = cursor.current.authGeneration !== null + && cursor.current.authGeneration !== effectiveAuthGeneration; if ( cursor.current.sessionId !== sessionId || cursor.current.cursorResetEpoch !== cursorResetEpoch + || authChanged ) { - cursor.current = { sessionId, cursorResetEpoch, lastEventId: "" }; + cursor.current = { sessionId, cursorResetEpoch, authGeneration: effectiveAuthGeneration, lastEventId: "" }; + if (authChanged) resetSession(); } - if (!sessionId) { + if (!sessionId || !authenticated) { activeSource.current = null; setConnected(false); return; @@ -96,19 +117,40 @@ export function useSessionStream( const query = cursor.current.lastEventId ? `?lastEventId=${encodeURIComponent(cursor.current.lastEventId)}` : ""; + const sourceAuthGeneration = effectiveAuthGeneration; const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`)); const identity = { source: es, sessionId, cursorResetEpoch }; - const coalescer = createStreamEventCoalescer(applyEvent); + let authProbeStarted = false; + const isCurrentSource = () => activeSource.current === identity + && isAuthGenerationCurrent(sourceAuthGeneration); + const probeAuth = async () => { + if (authProbeStarted || !isCurrentSource()) return; + authProbeStarted = true; + const operation = captureAuthOperation({ sessionId, disposalEpoch: 0 }); + if (!operation) return; + try { + await getMe(); + if (!isCurrentSource() || !isAuthOperationCurrent(operation, { sessionId, disposalEpoch: 0 })) return; + setConnected(true); + } catch { + if (!isCurrentSource() || !isAuthOperationCurrent(operation, { sessionId, disposalEpoch: 0 })) return; + setConnected(false); + } + }; + const coalescer = createStreamEventCoalescer(applyEvent, STREAM_UPDATE_INTERVAL_MS, isCurrentSource); activeSource.current = identity; es.onopen = () => { - if (activeSource.current === identity) setConnected(true); + if (!isCurrentSource()) { es.close(); return; } + setConnected(true); }; es.onerror = () => { - if (activeSource.current === identity) setConnected(false); + if (!isCurrentSource()) { es.close(); return; } + setConnected(false); + void probeAuth(); }; const handle = (ev: MessageEvent) => { - if (activeSource.current !== identity) return; + if (!isCurrentSource()) return; if (ev.lastEventId) cursor.current.lastEventId = ev.lastEventId; try { coalescer.push(JSON.parse(ev.data) as StreamEvent); @@ -138,10 +180,12 @@ export function useSessionStream( coalescer.dispose(); if (activeSource.current === identity) { activeSource.current = null; - setConnected(false); + if (getAuthGeneration() === sourceAuthGeneration) { + setConnected(false); + } } }; - }, [sessionId, generation, cursorResetEpoch, applyEvent]); + }, [sessionId, generation, cursorResetEpoch, authenticated, effectiveAuthGeneration, applyEvent, resetSession]); return { connected }; } diff --git a/frontend/src/test/fakeEventSource.ts b/frontend/src/test/fakeEventSource.ts index bbce75dd..65ecfdbb 100644 --- a/frontend/src/test/fakeEventSource.ts +++ b/frontend/src/test/fakeEventSource.ts @@ -2,7 +2,7 @@ export class FakeEventSource { static instances: FakeEventSource[] = []; onmessage: ((e: { data: string; lastEventId: string }) => void) | null = null; onopen: (() => void) | null = null; - onerror: (() => void) | null = null; + onerror: ((event?: unknown) => void) | null = null; closed = false; private listeners = new Map void>>(); private historicalListeners = new Map< diff --git a/frontend/src/test/msw.ts b/frontend/src/test/msw.ts index cb00660a..1391976b 100644 --- a/frontend/src/test/msw.ts +++ b/frontend/src/test/msw.ts @@ -1,5 +1,13 @@ import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; export const server = setupServer( + http.get("/api/auth/config", () => HttpResponse.json({ mode: "local", localLogin: true, oidcLogin: false })), + http.get("/api/me", () => HttpResponse.json({ + issuer: "mock", subject: "test-user", displayName: "Test user", roles: ["admin"], + permissions: [ + "session.use", "session.read_all", "session.manage_all", "settings.manage", + "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read", + ], isAdmin: true, csrfToken: "c".repeat(43), session: null, + })), http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })), );