feat(auth): add remembered local login to the frontend

This commit is contained in:
2026-08-17 04:52:39 +02:00
parent 8c67cb75dc
commit 202822f3ba
36 changed files with 2649 additions and 163 deletions
+150
View File
@@ -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: () => (
<div data-testid="authenticated-shell">
Authenticated shell
<button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>Log out</button>
</div>
),
}));
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(<AuthGate />);
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(<AuthGate />);
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(<AuthGate />);
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(<AuthGate />);
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(<AuthGate />);
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(<AuthGate />);
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(<AuthGate />);
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(<StrictMode><AuthGate /></StrictMode>);
expect(await screen.findByTestId("authenticated-shell")).toBeInTheDocument();
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
} finally {
errors.mockRestore();
}
});
});
+111
View File
@@ -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
? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} />
: null;
}
export function AuthGate() {
const [status, setStatus] = useState<GateStatus>("loading");
const [config, setConfig] = useState<AuthPublicConfig>();
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 <main className="grid min-h-screen place-items-center bg-background" role="status" aria-label="Checking access"><p className="text-sm text-muted-foreground">Checking access…</p></main>;
}
if (status === "authenticated") {
return <AuthenticatedContent onExpired={() => setStatus("login")} />;
}
if (status === "unavailable") {
return (
<main className="grid min-h-screen place-items-center bg-background px-5" role="status" aria-label="Authentication unavailable">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-7 text-center shadow-md">
<p className="thot-label text-primary">ThothII access</p>
<h1 className="mt-3 font-heading text-3xl font-semibold">Authentication unavailable</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">The authentication provider could not be reached. Try again in a moment.</p>
<Button className="mt-6" onClick={retry}>Retry</Button>
</section>
</main>
);
}
if (status === "forbidden") {
return (
<main className="grid min-h-screen place-items-center bg-background px-5">
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-7 text-center shadow-md">
<p className="thot-label text-primary">ThothII access</p>
<h1 className="mt-3 font-heading text-3xl font-semibold">Access not permitted</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">You are signed in without permission to use this workspace. Contact the installation administrator.</p>
</section>
</main>
);
}
return config ? <LoginPage config={config} onAuthenticated={(_authenticated: AuthenticatedUser) => {
setStatus("authenticated");
}} onRetry={retry} /> : null;
}
+239
View File
@@ -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(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
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(<LoginPage config={localConfig} onAuthenticated={onAuthenticated} />);
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(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
expect(screen.queryByRole("link", { name: /single sign-on/i })).not.toBeInTheDocument();
rerender(<LoginPage config={oidcConfig} onAuthenticated={vi.fn()} />);
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<void>((resolve) => { releaseLogout = resolve; });
const logoutRequest = new Promise<void>((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(<LoginPage config={localConfig} onAuthenticated={vi.fn()} onRetry={retry} />);
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(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
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(<StrictMode><LoginPage config={localConfig} onAuthenticated={onAuthenticated} /></StrictMode>);
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(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
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<void>((resolve) => { release = resolve; });
let settled!: () => void;
const settledAfterUnmount = new Promise<void>((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(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
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();
}
});
});
+161
View File
@@ -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<HTMLFormElement>(null);
const passwordRef = useRef<HTMLInputElement>(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<HTMLFormElement>) {
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 (
<main className="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 sm:py-12">
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-5xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_26rem]">
<section className="hidden max-w-xl lg:block">
<p className="thot-label text-primary">Secure re-entry · governed workspace</p>
<h1 className="mt-4 max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">
Return to the analytical ledger.
</h1>
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">
ThothII keeps each question, review decision, and SQL artifact inside a traceable human-in-the-loop workflow.
</p>
<div className="mt-10 flex items-center gap-3 text-sm text-muted-foreground">
<span className="h-px w-16 bg-primary" aria-hidden="true" />
<span>Access is checked before the workspace opens.</span>
</div>
</section>
<section className="mx-auto w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-md sm:p-8">
<div className="mb-7">
<div className="flex items-center gap-2 text-primary" aria-hidden="true">
<LockKeyhole className="size-4" />
<span className="thot-label text-primary">ThothII access</span>
</div>
<h2 className="mt-3 font-heading text-3xl font-semibold tracking-tight">Sign in to ThothII</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">Use your installation account to continue.</p>
</div>
{error && (
<div role="alert" aria-live="assertive" className="mb-5 grid gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p className="flex items-start gap-2 leading-5"><AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />{error.message}</p>
{error.retry && onRetry && (
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetry}>Retry</Button>
)}
</div>
)}
{localLogin && (
<form ref={formRef} onSubmit={submit} className="grid gap-4">
<div className="grid gap-1.5">
<label htmlFor="thothii-username" className="text-sm font-semibold">Username</label>
<input
id="thothii-username"
name="username"
type="text"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="thothii-password" className="text-sm font-semibold">Password</label>
<input
ref={passwordRef}
id="thothii-password"
name="password"
type="password"
autoComplete="current-password"
required
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input name="remember" type="checkbox" className="size-4 accent-[oklch(var(--primary))]" />
<span>Remember me for 30 days</span>
</label>
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting}>
{submitting ? "Signing in…" : "Sign in"}
{!submitting && <ArrowRight aria-hidden="true" />}
</Button>
</form>
)}
{config.oidcLogin && (
<a
href="/api/auth/oidc/login"
className="mt-4 inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border border-border bg-card px-4 text-sm font-semibold shadow-xs outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/25"
>
Continue with single sign-on
<ArrowRight aria-hidden="true" className="size-4" />
</a>
)}
{!localLogin && !config.oidcLogin && (
<p role="status" className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
No browser sign-in method is enabled for this installation.
</p>
)}
</section>
</div>
</main>
);
}
+40
View File
@@ -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();
});
+65
View File
@@ -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();
}
+57
View File
@@ -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([]);
});
+71
View File
@@ -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<AuthenticatedUser, "permissions"> | null | undefined, permission: string): boolean {
return user?.permissions.includes(permission) ?? false;
}