diff --git a/.env.example b/.env.example index 122724e..3c41496 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ VITE_PROOFLINE_API_BASE_URL=http://127.0.0.1:8080 VITE_PROOFLINE_API_MODE=mock +VITE_PROOFLINE_AUTH_MODE=bearer VITE_PROOFLINE_SESSION_STORAGE=memory diff --git a/CHANGELOG.md b/CHANGELOG.md index e0754dd..baa5183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Implemented an explicit browser-cookie auth client mode with in-memory CSRF + handling. - Enabled live owned-incident listing against authenticated `GET /v1/incidents` responses. - Added the reusable documentation and prompt review workflow and refreshed diff --git a/README.md b/README.md index 42822b6..94ed249 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,16 @@ Use live mode only against a reviewed local backend: ```text VITE_PROOFLINE_API_MODE=live +VITE_PROOFLINE_AUTH_MODE=bearer VITE_PROOFLINE_API_BASE_URL=http://127.0.0.1:8080 ``` +For reviewed local browser-cookie auth testing, also set: + +```text +VITE_PROOFLINE_AUTH_MODE=cookie +``` + ## Development ```bash @@ -87,7 +94,7 @@ The current bootstrap includes: - authenticated app shell - conservative session state with memory-first token storage - optional local-storage session persistence for local development only -- documented browser-cookie auth and CSRF client-mode planning boundary +- explicit bearer-token and browser-cookie auth client modes - incident list UI backed by mock data in prototype mode and authenticated owner-scoped `GET /v1/incidents` responses in live mode - incident detail metadata UI @@ -114,8 +121,8 @@ Planned account portal work includes: - clear account-disabled, payment-required, expired-session, unauthorized, and forbidden states - browser-safe API error handling - browser token-storage review and hardening -- browser-cookie auth mode and CSRF handling, once server/deployment review - approves credentialed CORS for exact origins +- deployment review for browser-cookie auth, credentialed CORS, and exact + reviewed origins Payment-gated registration must be implemented as a backend-supported account lifecycle, not just a frontend form. The current server paid-registration mode @@ -255,11 +262,11 @@ do not imply production readiness or public `/v1` API readiness. ## API Boundary -The server currently confirms bearer session auth, `POST /v1/auth/login`, -`POST /v1/auth/register`, `POST /v1/auth/email/verify`, -`POST /v1/auth/logout`, `GET /v1/account`, owner-scoped incident list/detail -routes, contact public-key routes, sharing-grant routes, and wrapped-key -routes. Current `open-proofline/server` documents authenticated +The server currently confirms bearer session auth, browser-cookie auth routes, +`POST /v1/auth/register`, `POST /v1/auth/email/verify`, `GET /v1/account`, +owner-scoped incident list/detail routes, contact public-key routes, +sharing-grant routes, and wrapped-key routes. Current `open-proofline/server` +documents authenticated `GET /v1/incidents`, and this client parses that response shape in live mode. Mock mode uses prototype incident records only and must not be treated as backend truth. @@ -285,9 +292,11 @@ private deployment details, or user safety data. ## Session Storage -Session tokens are kept in memory by default. A local-storage adapter exists for -developer convenience only behind `VITE_PROOFLINE_SESSION_STORAGE=localStorage`. -Browser token persistence must be reviewed before any production use. +Bearer session tokens are kept in memory by default. A local-storage adapter +exists for developer convenience only behind +`VITE_PROOFLINE_SESSION_STORAGE=localStorage`. Browser token persistence must +be reviewed before any production use. Cookie-mode sessions do not store bearer +tokens; the browser session cookie is HttpOnly and managed by the server. ## Catalyst And Tailwind diff --git a/docs/README.md b/docs/README.md index 47d28dc..a5f986a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,8 +4,8 @@ These docs describe the current experimental web-client prototype. The backend source of truth remains `open-proofline/server`. - [Architecture](architecture.md) -- [API client](api-client.md): current route contracts and browser-cookie auth - planning boundary. +- [API client](api-client.md): current route contracts and bearer/cookie auth + client modes. - [Security model](security-model.md): implemented controls, non-controls, and browser auth review areas. - [Browser security headers](browser-security-headers.md): static-host header diff --git a/docs/api-client.md b/docs/api-client.md index a7ba1b3..3ef00da 100644 --- a/docs/api-client.md +++ b/docs/api-client.md @@ -16,6 +16,14 @@ prototype data so browser smoke tests do not require a live backend. `VITE_PROOFLINE_API_MODE=live` calls the current server API. +Live mode supports two explicit auth modes: + +- `VITE_PROOFLINE_AUTH_MODE=bearer` uses bearer-session login/logout and omits + browser credentials from API requests. +- `VITE_PROOFLINE_AUTH_MODE=cookie` uses the browser-cookie auth routes, + includes browser credentials only for cookie-authenticated requests, and + never attaches an `Authorization` header. + ## Confirmed Backend Routes From current `open-proofline/server` docs and route registration: @@ -91,22 +99,22 @@ Mock mode returns explicit prototype-only responses for these methods; it does not create accounts, send email, verify real tokens, or model payment/billing state. -## Browser Cookie Auth And CSRF Planning Boundary +## Browser Cookie Auth And CSRF -The current frontend implementation uses bearer-session auth in live mode: -`POST /v1/auth/login` returns a bearer token, authenticated requests attach -`Authorization: Bearer ...`, and session storage is memory-first with optional -local-storage persistence for local development only. +Bearer mode remains the default live mode. `POST /v1/auth/login` returns a +bearer token, authenticated bearer-mode requests attach +`Authorization: Bearer ...`, and bearer session storage is memory-first with +optional local-storage persistence for local development only. Bearer-mode +fetches use `credentials: "omit"` so browser session cookies are not relied on. -`open-proofline/server` also documents browser-cookie auth routes for a future -web-client mode: +Cookie mode is selected explicitly with `VITE_PROOFLINE_AUTH_MODE=cookie` and +uses the server browser-cookie auth routes: - `POST /v1/auth/web/login` - `POST /v1/auth/web/logout` - `GET /v1/auth/web/csrf` -That mode is not implemented in this client yet. When it is implemented, the -API client must choose one credential mode per live client instance: +The API client chooses one credential mode per live client instance: - bearer mode: call the existing bearer login/logout routes and never send `credentials: "include"` for session cookies; @@ -116,26 +124,26 @@ API client must choose one credential mode per live client instance: The modes must not be mixed for the same request. Current server behavior rejects requests that include both bearer credentials and a browser session -cookie with `400 ambiguous_credentials`; tests for a cookie-mode implementation -should assert that authenticated requests cannot add both. +cookie with `400 ambiguous_credentials`; the client treats that as a local +invariant and refuses to send bearer tokens from cookie-mode authenticated +requests. -Cookie-mode CSRF handling should be explicit in the client contract: +Cookie-mode CSRF handling is explicit in the client contract: - fetch the CSRF token from `GET /v1/auth/web/csrf` after a successful cookie login and before the first unsafe cookie-authenticated request; - cache the token in memory only, scoped to the active browser session; - attach the returned header name, defaulting to `X-CSRF-Token` per current server docs, to unsafe methods such as `POST` and `PATCH`; -- refresh the token after login, after a `403 csrf_required`, and after any - auth/session reset; +- refresh the token after login, after a `403 csrf_required`, and before unsafe + cookie-authenticated requests when no in-memory CSRF token is available; - clear the cached token on logout and when account/session state is cleared. Credentialed CORS is a deployment boundary, not a frontend-only switch. A cookie-mode client must be used only with exact reviewed origins configured in `open-proofline/server`; wildcard origins are not acceptable for credentialed -requests. Browser tests should cover web login, CSRF fetch, unsafe request -header attachment, logout cleanup, and failure behavior when the CSRF token is -missing or rejected. +requests. Browser tests cover web login, CSRF fetch, unsafe request header +attachment, logout cleanup, and rejected CSRF refresh behavior. ## Logging Boundary diff --git a/docs/architecture.md b/docs/architecture.md index 136be24..6da6b5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,9 +15,9 @@ flowchart LR - The app handles account login, public registration, email verification, and incident metadata review. -- The current live API client uses bearer-token auth. Browser-cookie auth and - CSRF handling remain a documented future client-mode boundary, not an - implemented runtime mode. +- The live API client supports explicit bearer-token and browser-cookie auth + modes. Cookie mode uses server-managed HttpOnly cookies, in-memory CSRF + tokens, and `credentials: "include"` only for cookie-authenticated requests. - The app does not record media. - The app does not decrypt chunks or unwrap wrapped keys. - The app does not export playable media. diff --git a/docs/browser-security-headers.md b/docs/browser-security-headers.md index 48ec332..3ec0046 100644 --- a/docs/browser-security-headers.md +++ b/docs/browser-security-headers.md @@ -22,10 +22,10 @@ approval. Backend behavior and public API readiness remain governed by requires separate server, deployment, abuse-control, credential-storage, CSRF, logging, and operations review. -If a future web-client mode uses the server browser-session cookie routes, -static hosting review must also cover credentialed CORS. The API origin must be -an exact reviewed origin configured in `open-proofline/server`; wildcard -origins are not acceptable for credentialed requests. +If the web client uses the server browser-session cookie mode, static hosting +review must also cover credentialed CORS. The API origin must be an exact +reviewed origin configured in `open-proofline/server`; wildcard origins are not +acceptable for credentialed requests. ## Recommended Starting Set @@ -63,11 +63,11 @@ readiness than the server docs and deployment review support. ### Credentialed CORS And CSRF -The current implementation uses bearer-token live auth and does not implement -browser-cookie auth. If browser-cookie auth is added later, the frontend must -send `credentials: "include"` only to the reviewed API origin, must not attach -an `Authorization` header in cookie mode, and must attach the server-provided -CSRF header to unsafe cookie-authenticated requests. +The current implementation uses bearer-token live auth by default and supports +an explicit browser-cookie auth mode. Cookie mode sends +`credentials: "include"` only to the reviewed API origin, does not attach an +`Authorization` header, and attaches the server-provided CSRF header to unsafe +cookie-authenticated requests. Static headers cannot make credentialed CORS safe by themselves. Server configuration must use exact allowed origins, secure cookie settings for public @@ -137,8 +137,8 @@ for HTTPS-only access. - CSP names only the static origin and reviewed API origin. - Credentialed CORS, if used, is limited to exact reviewed origins and not `*`. -- Cookie-auth requests, if implemented, do not also attach bearer credentials. -- Unsafe cookie-auth requests, if implemented, attach the reviewed CSRF header. +- Cookie-auth requests do not also attach bearer credentials. +- Unsafe cookie-auth requests attach the reviewed CSRF header. - No public edge routes private admin surfaces such as `/v1/admin/...`. - `nosniff`, referrer policy, permissions policy, and frame restrictions are present on the static app. diff --git a/docs/development.md b/docs/development.md index f8aaf20..ef121a1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,11 +31,16 @@ does not require a live backend for the bootstrap smoke test. ```text VITE_PROOFLINE_API_BASE_URL=http://127.0.0.1:8080 VITE_PROOFLINE_API_MODE=mock +VITE_PROOFLINE_AUTH_MODE=bearer VITE_PROOFLINE_SESSION_STORAGE=memory ``` Use `VITE_PROOFLINE_API_MODE=live` only when a local backend is running and the route assumptions have been checked against `open-proofline/server/docs/api.md`. -The current live client uses bearer-token auth. There is no browser-cookie auth -environment switch yet; do not add one without updating the API client contract, -CSRF handling, tests, and deployment guidance together. +The default live auth mode is bearer-token auth. For local browser-cookie auth +testing, use `VITE_PROOFLINE_AUTH_MODE=cookie` only with a backend configured +for reviewed local origins, for example `SAFE_WEB_AUTH_ENABLED=true`, +`SAFE_WEB_ALLOWED_ORIGINS=http://127.0.0.1:5173`, a non-`__Host-` local cookie +name, and `SAFE_WEB_SESSION_COOKIE_SECURE=false`. Production cookie mode still +requires HTTPS, exact allowed origins, secure cookies, CSRF review, and public +API deployment review in `open-proofline/server`. diff --git a/docs/security-model.md b/docs/security-model.md index a066d00..51b721a 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -7,7 +7,11 @@ This web client is experimental and not production-ready. - Session tokens are kept in memory by default. - Optional local-storage session persistence is behind `VITE_PROOFLINE_SESSION_STORAGE=localStorage` for local development only. +- Browser-cookie auth mode stores session state in an HttpOnly server cookie + and keeps CSRF tokens in API-client memory only. - Expired or malformed loaded sessions are cleared before authenticating the UI. +- Loaded sessions are cleared when the configured API mode or auth mode no + longer matches the stored session metadata. - API responses are parsed with Zod before use where route shapes are known. - UI states avoid showing raw tokens, Authorization headers, request bodies, plaintext, raw keys, wrapped-key ciphertext, stored paths, or object keys. @@ -33,13 +37,14 @@ persisted in browser storage, screenshotted, copied into public issue drafts, included in analytics, or exposed in UI beyond the transient browser URL fragment needed to complete verification. -## Browser Cookie Auth And CSRF Planning Boundary +## Browser Cookie Auth And CSRF -The implemented live client remains bearer-token based. A future browser-cookie -auth mode must be a separate client mode, not an additive flag on top of bearer -auth. In that mode, authenticated requests would use -`credentials: "include"` with the reviewed API origin and must not attach an -`Authorization` header. Bearer mode must not rely on browser session cookies. +The implemented live client supports bearer-token auth and explicit +browser-cookie auth mode. Cookie auth is a separate client mode, not an +additive flag on top of bearer auth. In that mode, authenticated requests use +`credentials: "include"` with the reviewed API origin and do not attach an +`Authorization` header. Bearer mode uses `credentials: "omit"` and must not +rely on browser session cookies. Current `open-proofline/server` behavior rejects mixed bearer and browser-cookie credentials with `400 ambiguous_credentials`. The frontend must treat that as a @@ -47,12 +52,12 @@ security invariant: a request builder should make the credential mode unambiguous before the request is sent, and tests should verify that both credential types cannot be attached together. -Cookie-authenticated unsafe requests require a session-bound CSRF header. A -future implementation should fetch the token from `GET /v1/auth/web/csrf`, -store it in memory only, attach the returned header name to unsafe methods, and -clear it on logout or session reset. Missing or rejected CSRF tokens should -produce a controlled error state and a token refresh attempt where appropriate, -not a fallback to bearer credentials. +Cookie-authenticated unsafe requests require a session-bound CSRF header. The +client fetches the token from `GET /v1/auth/web/csrf`, stores it in memory only, +attaches the returned header name to unsafe methods, refreshes after +`403 csrf_required`, and clears it on logout or session reset. Missing or +rejected CSRF tokens produce controlled request failures or token refreshes, not +a fallback to bearer credentials. Credentialed CORS and cookie attributes are deployment-sensitive server configuration. The web client documentation may describe the required diff --git a/docs/threat-model.md b/docs/threat-model.md index b7cba02..9814593 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -6,9 +6,8 @@ in `open-proofline/server`. ## Assets - Opaque bearer session tokens returned by the server. -- Future browser session cookies, if cookie auth mode is implemented. -- Future CSRF tokens for cookie-authenticated unsafe requests, if cookie auth - mode is implemented. +- Browser session cookies when cookie auth mode is enabled. +- CSRF tokens for cookie-authenticated unsafe requests. - Raw email-verification tokens carried in verification URL fragments. - Account metadata visible to the authenticated user. - Incident, stream, chunk, contact public-key, sharing-grant, and wrapped-key @@ -20,8 +19,8 @@ in `open-proofline/server`. - Browser JavaScript is not trusted with raw media keys in this prototype. - The backend remains authoritative for authorization. - Registration availability and account activation are backend decisions. -- Browser-cookie auth, credentialed CORS, cookie attributes, and CSRF header - names remain server/deployment decisions until a client mode is implemented. +- Credentialed CORS, cookie attributes, and CSRF header names remain + server/deployment decisions. - Bearer-token auth and browser-cookie auth must remain mutually exclusive for a given authenticated request. - Catalyst components are app-internal UI source, not a redistributed kit. @@ -35,9 +34,9 @@ in `open-proofline/server`. debugging tools, copied issue text, or analytics if handled carelessly. - Registration UI wording could expose account-existence state if it diverges from the server's generic verification-required response. -- A future cookie-auth mode could accidentally mix bearer and cookie - credentials, triggering server rejection and weakening client-side reasoning - about which credential protects the request. +- Cookie-auth mode could accidentally mix bearer and cookie credentials, + triggering server rejection and weakening client-side reasoning about which + credential protects the request. - Missing, stale, logged, or over-persisted CSRF tokens could break unsafe cookie-authenticated requests or expand the effect of XSS. - Credentialed CORS configured with broad or unreviewed origins could expose @@ -50,6 +49,6 @@ in `open-proofline/server`. ## Out Of Scope Recording, decryption, key escrow, break-glass access, trusted-contact -decryption, browser-cookie auth implementation, payment processing, -public-production account portal claims, emergency notifications, and playable -media export are out of scope until explicitly designed and reviewed. +decryption, payment processing, public-production account portal claims, +emergency notifications, and playable media export are out of scope until +explicitly designed and reviewed. diff --git a/src/App.test.tsx b/src/App.test.tsx index ec24686..16d7700 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -27,6 +27,7 @@ function renderRoute(path: string) { beforeEach(() => { vi.stubEnv("VITE_PROOFLINE_API_MODE", "mock"); + vi.stubEnv("VITE_PROOFLINE_AUTH_MODE", "bearer"); }); afterEach(() => { @@ -360,6 +361,89 @@ test("keeps generic login failures generic", async () => { ).toBeNull(); }); +test("logs in and out with browser-cookie auth mode", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + vi.stubEnv("VITE_PROOFLINE_AUTH_MODE", "cookie"); + let csrfRequests = 0; + let incidentListRequests = 0; + let logoutRequests = 0; + server.use( + http.post("*/v1/auth/web/login", async ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + await expect(request.json()).resolves.toEqual({ + username: "cookie-user", + password: "valid-password", + }); + return HttpResponse.json( + { + session_id: "ses_cookie", + account: { + id: "acct_cookie", + username: "cookie-user", + role: "user", + }, + token: "raw-cookie-session-token-must-not-display", + created_at: "2026-06-01T00:00:00Z", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + { status: 201 }, + ); + }), + http.get("*/v1/auth/web/csrf", ({ request }) => { + csrfRequests += 1; + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + return HttpResponse.json({ + csrf_token: "csrf-token", + header_name: "X-CSRF-Token", + }); + }), + http.get("*/v1/incidents", ({ request }) => { + incidentListRequests += 1; + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + return HttpResponse.json({ incidents: [] }); + }), + http.post("*/v1/auth/web/logout", ({ request }) => { + logoutRequests += 1; + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + expect(request.headers.get("x-csrf-token")).toBe("csrf-token"); + return HttpResponse.json({ revoked: true }); + }), + ); + + renderRoute("/login"); + + fireEvent.change(await screen.findByLabelText("Username"), { + target: { value: "cookie-user" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "valid-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + + expect( + await screen.findByRole("heading", { name: "Account overview" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("Account menu")); + expect(screen.getByText("cookie-user")).toBeInTheDocument(); + expect(screen.queryByText("raw-cookie-session-token-must-not-display")).toBe( + null, + ); + expect(screen.queryByText("csrf-token")).toBeNull(); + + fireEvent.click(screen.getByRole("menuitem", { name: "Sign out" })); + + expect( + await screen.findByRole("heading", { name: "Sign in" }), + ).toBeInTheDocument(); + expect(csrfRequests).toBe(1); + expect(incidentListRequests).toBeGreaterThanOrEqual(1); + expect(logoutRequests).toBe(1); +}); + test("verifies email links and clears URL fragments", async () => { vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); window.history.pushState(null, "", "/verify-email#token=unit-token"); @@ -441,6 +525,19 @@ test("redirects authenticated login visits to the dashboard", async () => { expect(screen.queryByRole("heading", { name: "Sign in" })).toBeNull(); }); +test("clears stale bearer sessions when cookie auth mode is configured", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + vi.stubEnv("VITE_PROOFLINE_AUTH_MODE", "cookie"); + saveLiveSession(); + + renderRoute("/"); + + expect( + await screen.findByRole("heading", { name: "Sign in" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Account overview")).toBeNull(); +}); + test("logs in with mock credentials and renders the dashboard", async () => { renderRoute("/login"); @@ -665,5 +762,6 @@ function saveTestSession(mode: "mock" | "live", username: string) { createdAt: "2026-06-01T00:00:00Z", expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), mode, + authMode: "bearer", }); } diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 05daea9..81600de 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -1,13 +1,22 @@ import { http, HttpResponse } from "msw"; -import { expect, test } from "vitest"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { server } from "../test/setup"; -import { createProoflineApiClient } from "./client"; +import { CredentialModeError, createProoflineApiClient } from "./client"; import { ApiError, safeErrorMessage } from "./errors"; +beforeEach(() => { + vi.stubEnv("VITE_PROOFLINE_AUTH_MODE", "bearer"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + test("parses live account responses with zod", async () => { server.use( - http.get("*/v1/account", () => - HttpResponse.json({ + http.get("*/v1/account", ({ request }) => { + expect(request.credentials).toBe("omit"); + return HttpResponse.json({ account: { id: "acct_live", username: "live-user", @@ -19,8 +28,8 @@ test("parses live account responses with zod", async () => { updated_at: "2026-06-01T00:30:00Z", password_changed_at: "2026-06-01T00:15:00Z", }, - }), - ), + }); + }), ); const client = createProoflineApiClient({ @@ -41,6 +50,182 @@ test("parses live account responses with zod", async () => { }); }); +test("parses cookie login responses without retaining raw tokens", async () => { + server.use( + http.post("*/v1/auth/web/login", async ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + await expect(request.json()).resolves.toEqual({ + username: "cookie-user", + password: "valid-password", + }); + return HttpResponse.json( + { + session_id: "ses_cookie", + account: { + id: "acct_cookie", + username: "cookie-user", + role: "user", + }, + token: "raw-cookie-session-token-must-not-retain", + created_at: "2026-06-01T00:00:00Z", + expires_at: "2026-06-01T01:00:00Z", + }, + { status: 201 }, + ); + }), + http.get("*/v1/auth/web/csrf", ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + return HttpResponse.json({ + csrf_token: "csrf-token", + header_name: "X-CSRF-Token", + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + authMode: "cookie", + }); + + const response = await client.login({ + username: "cookie-user", + password: "valid-password", + }); + + expect(response).toEqual({ + session_id: "ses_cookie", + account: { + id: "acct_cookie", + username: "cookie-user", + role: "user", + }, + created_at: "2026-06-01T00:00:00Z", + expires_at: "2026-06-01T01:00:00Z", + }); + expect("token" in response).toBe(false); +}); + +test("uses cookie credentials without authorization headers for authenticated reads", async () => { + server.use( + http.get("*/v1/account", ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + return HttpResponse.json({ + account: { + id: "acct_cookie", + username: "cookie-user", + role: "user", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + authMode: "cookie", + }); + + await expect(client.getCurrentAccount()).resolves.toMatchObject({ + id: "acct_cookie", + username: "cookie-user", + }); +}); + +test("attaches cookie CSRF headers to unsafe cookie logout requests", async () => { + server.use( + http.get("*/v1/auth/web/csrf", ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + return HttpResponse.json({ + csrf_token: "csrf-token", + header_name: "X-CSRF-Token", + }); + }), + http.post("*/v1/auth/web/logout", ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + expect(request.headers.get("x-csrf-token")).toBe("csrf-token"); + return HttpResponse.json({ revoked: true }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + authMode: "cookie", + }); + + await expect(client.logout()).resolves.toBeUndefined(); +}); + +test("refreshes cookie CSRF state once after a rejected unsafe request", async () => { + let csrfFetches = 0; + let logoutAttempts = 0; + server.use( + http.get("*/v1/auth/web/csrf", () => { + csrfFetches += 1; + return HttpResponse.json({ + csrf_token: csrfFetches === 1 ? "stale-csrf" : "fresh-csrf", + header_name: "X-CSRF-Token", + }); + }), + http.post("*/v1/auth/web/logout", ({ request }) => { + logoutAttempts += 1; + if (logoutAttempts === 1) { + expect(request.headers.get("x-csrf-token")).toBe("stale-csrf"); + return HttpResponse.json( + { + error: { + code: "csrf_required", + message: "CSRF token is required", + }, + }, + { status: 403 }, + ); + } + expect(request.headers.get("x-csrf-token")).toBe("fresh-csrf"); + return HttpResponse.json({ revoked: true }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + authMode: "cookie", + }); + + await expect(client.logout()).resolves.toBeUndefined(); + expect(csrfFetches).toBe(2); + expect(logoutAttempts).toBe(2); +}); + +test("rejects mixed cookie mode and bearer tokens before sending authenticated requests", async () => { + let requestCount = 0; + server.use( + http.get("*/v1/account", () => { + requestCount += 1; + return HttpResponse.json({ + account: { + id: "acct_cookie", + username: "cookie-user", + role: "user", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + authMode: "cookie", + getToken: () => "test-session-token", + }); + + await expect(client.getCurrentAccount()).rejects.toBeInstanceOf( + CredentialModeError, + ); + expect(requestCount).toBe(0); +}); + test("submits public registration and parses accepted responses", async () => { server.use( http.post("*/v1/auth/register", async ({ request }) => { diff --git a/src/api/client.ts b/src/api/client.ts index 06fc505..02dc231 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -9,8 +9,11 @@ import { registrationAcceptedResponseSchema, sharingGrantResponseSchema, sharingGrantsResponseSchema, + webCSRFResponseSchema, + webLoginResponseSchema, wrappedKeyResponseSchema, wrappedKeysResponseSchema, + type AuthMode, type Account, type ContactPublicKey, type EmailVerificationResponse, @@ -19,15 +22,18 @@ import { type LoginResponse, type RegistrationAcceptedResponse, type SharingGrant, + type WebCSRFResponse, + type WebLoginResponse, type WrappedKey, } from "./schemas"; import { apiErrorFromResponse } from "./errors"; -type ClientMode = "mock" | "live"; +export type ClientMode = "mock" | "live"; type ClientOptions = { baseUrl?: string; mode?: ClientMode; + authMode?: AuthMode; getToken?: () => string | null; }; @@ -43,8 +49,25 @@ type VerifyAccountEmailRequest = { type RequestOptions = { includeAuth?: boolean; + includeCredentials?: boolean; + retryCSRF?: boolean; + skipCSRF?: boolean; }; +type WebCSRFState = { + token: string; + headerName: string; +}; + +export class CredentialModeError extends Error { + readonly code = "mixed_credential_mode"; + + constructor(message: string) { + super(message); + this.name = "CredentialModeError"; + } +} + export const prooflineQueryKeys = { account: ["account"] as const, incidents: ["incidents"] as const, @@ -58,10 +81,22 @@ export const prooflineQueryKeys = { const defaultBaseUrl = import.meta.env.VITE_PROOFLINE_API_BASE_URL ?? "http://127.0.0.1:8080"; -function defaultClientMode(): ClientMode { +export function defaultProoflineClientMode(): ClientMode { return import.meta.env.VITE_PROOFLINE_API_MODE === "live" ? "live" : "mock"; } +export function defaultProoflineAuthMode( + mode: ClientMode = defaultProoflineClientMode(), +): AuthMode { + if (mode !== "live") { + return "bearer"; + } + const configured = import.meta.env.VITE_PROOFLINE_AUTH_MODE; + return configured === "cookie" || configured === "browser-cookie" + ? "cookie" + : "bearer"; +} + const mockAccount: Account = { id: "acct_prototype", username: "prototype-user", @@ -225,18 +260,24 @@ const mockWrappedKeys: WrappedKey[] = [mockWrappedKey]; export class ProoflineApiClient { readonly baseUrl: string; readonly mode: ClientMode; + readonly authMode: AuthMode; private readonly getToken: () => string | null; + private webCSRF: WebCSRFState | null = null; constructor(options: ClientOptions = {}) { this.baseUrl = (options.baseUrl ?? defaultBaseUrl).replace(/\/+$/, ""); - this.mode = options.mode ?? defaultClientMode(); + this.mode = options.mode ?? defaultProoflineClientMode(); + this.authMode = + this.mode === "live" + ? (options.authMode ?? defaultProoflineAuthMode(this.mode)) + : "bearer"; this.getToken = options.getToken ?? (() => null); } async login(credentials: { username: string; password: string; - }): Promise { + }): Promise { if (this.mode === "mock") { return { session_id: "ses_prototype", @@ -247,6 +288,21 @@ export class ProoflineApiClient { }; } + if (this.authMode === "cookie") { + const response = webLoginResponseSchema.parse( + await this.request( + "/v1/auth/web/login", + { + method: "POST", + body: JSON.stringify(credentials), + }, + { includeAuth: false, includeCredentials: true }, + ), + ); + await this.refreshWebCSRF(); + return response; + } + return loginResponseSchema.parse( await this.request( "/v1/auth/login", @@ -299,7 +355,14 @@ export class ProoflineApiClient { if (this.mode === "mock") { return; } - await this.request("/v1/auth/logout", { method: "POST" }); + try { + await this.request( + this.authMode === "cookie" ? "/v1/auth/web/logout" : "/v1/auth/logout", + { method: "POST" }, + ); + } finally { + this.clearAuthenticationState(); + } } async getCurrentAccount(): Promise { @@ -394,6 +457,29 @@ export class ProoflineApiClient { ).wrapped_key; } + async refreshWebCSRF(): Promise { + if (this.mode !== "live" || this.authMode !== "cookie") { + this.webCSRF = null; + return null; + } + const response = webCSRFResponseSchema.parse( + await this.request( + "/v1/auth/web/csrf", + {}, + { skipCSRF: true, retryCSRF: false }, + ), + ); + this.webCSRF = { + token: response.csrf_token, + headerName: response.header_name ?? "X-CSRF-Token", + }; + return response; + } + + clearAuthenticationState(): void { + this.webCSRF = null; + } + private async request( path: string, init: RequestInit = {}, @@ -404,18 +490,65 @@ export class ProoflineApiClient { headers.set("content-type", "application/json"); } - const token = options.includeAuth === false ? null : this.getToken(); + const includeAuth = options.includeAuth !== false; + const method = (init.method ?? "GET").toUpperCase(); + const usesCookieAuth = + this.authMode === "cookie" && + (includeAuth || options.includeCredentials === true); + + if (this.authMode === "cookie" && headers.has("authorization")) { + throw new CredentialModeError( + "Cookie auth mode cannot send Authorization headers.", + ); + } + + const token = + includeAuth && this.authMode === "bearer" ? this.getToken() : null; + if (includeAuth && this.authMode === "cookie" && this.getToken()) { + throw new CredentialModeError( + "Cookie auth mode cannot use a bearer token.", + ); + } if (token) { headers.set("authorization", `Bearer ${token}`); } + if ( + usesCookieAuth && + includeAuth && + requiresWebCSRF(method) && + !options.skipCSRF + ) { + if (!this.webCSRF) { + await this.refreshWebCSRF(); + } + if (this.webCSRF) { + headers.set(this.webCSRF.headerName, this.webCSRF.token); + } + } + const response = await fetch(`${this.baseUrl}${path}`, { ...init, headers, + credentials: usesCookieAuth ? "include" : "omit", }); if (!response.ok) { - throw await apiErrorFromResponse(response); + const apiError = await apiErrorFromResponse(response); + if ( + usesCookieAuth && + includeAuth && + requiresWebCSRF(method) && + !options.skipCSRF && + options.retryCSRF !== false && + apiError.status === 403 && + apiError.code === "csrf_required" + ) { + this.webCSRF = null; + await this.refreshWebCSRF(); + return this.request(path, init, { ...options, retryCSRF: false }); + } + throw apiError; } if (response.status === 204) { @@ -426,6 +559,17 @@ export class ProoflineApiClient { } } +function requiresWebCSRF(method: string): boolean { + switch (method) { + case "GET": + case "HEAD": + case "OPTIONS": + return false; + default: + return true; + } +} + export function createProoflineApiClient( options?: ClientOptions, ): ProoflineApiClient { diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 3600cd6..48eed61 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -33,15 +33,47 @@ export const emailVerificationResponseSchema = z.object({ status: z.literal("verified"), }); -export const sessionSchema = z.object({ - sessionId: z.string(), +export const authModeSchema = z.enum(["bearer", "cookie"]); + +export const webLoginResponseSchema = z.object({ + session_id: z.string(), account: accountSchema, - token: z.string(), - createdAt: z.string(), - expiresAt: z.string(), - mode: z.enum(["mock", "live"]), + created_at: z.string(), + expires_at: z.string(), }); +export const webCSRFResponseSchema = z.object({ + csrf_token: z.string(), + header_name: z.string().optional(), +}); + +export const sessionSchema = z + .object({ + sessionId: z.string(), + account: accountSchema, + token: z.string().optional(), + createdAt: z.string(), + expiresAt: z.string(), + mode: z.enum(["mock", "live"]), + authMode: authModeSchema.default("bearer"), + }) + .superRefine((session, context) => { + if (session.authMode === "bearer" && !session.token) { + context.addIssue({ + code: "custom", + message: "bearer sessions require a token", + path: ["token"], + }); + } + if (session.authMode === "cookie" && session.token) { + context.addIssue({ + code: "custom", + message: "cookie sessions must not store bearer tokens", + path: ["token"], + }); + } + }); + export const incidentSchema = z.object({ id: z.string(), created_at: z.string().optional(), @@ -182,6 +214,9 @@ export type RegistrationAcceptedResponse = z.infer< export type EmailVerificationResponse = z.infer< typeof emailVerificationResponseSchema >; +export type AuthMode = z.infer; +export type WebLoginResponse = z.infer; +export type WebCSRFResponse = z.infer; export type Session = z.infer; export type Incident = z.infer; export type IncidentsResponse = z.infer; diff --git a/src/auth/session.test.ts b/src/auth/session.test.ts index 760781e..e77ab6e 100644 --- a/src/auth/session.test.ts +++ b/src/auth/session.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import type { Session } from "../api/schemas"; -import { clearSession, loadSession, saveSession } from "./session"; +import { + clearSession, + loadSession, + saveSession, + sessionFromLogin, +} from "./session"; const storageKey = "proofline.web-client.session"; const now = new Date("2026-06-01T00:00:00Z"); @@ -17,6 +22,7 @@ function session(overrides: Partial = {}): Session { createdAt: "2026-06-01T00:00:00Z", expiresAt: "2026-06-01T01:00:00Z", mode: "mock", + authMode: "bearer", ...overrides, }; } @@ -75,3 +81,73 @@ test("clears expired memory sessions on load", () => { expect(loadSession()).toBeNull(); }); + +test("loads legacy bearer sessions without an explicit auth mode", () => { + vi.stubEnv("VITE_PROOFLINE_SESSION_STORAGE", "localStorage"); + const legacySession = session(); + const storedSession: Partial = { ...legacySession }; + delete storedSession.authMode; + window.localStorage.setItem(storageKey, JSON.stringify(storedSession)); + + expect(loadSession()).toEqual(legacySession); +}); + +test("rejects persisted cookie sessions that contain bearer tokens", () => { + vi.stubEnv("VITE_PROOFLINE_SESSION_STORAGE", "localStorage"); + window.localStorage.setItem( + storageKey, + JSON.stringify(session({ authMode: "cookie" })), + ); + + expect(loadSession()).toBeNull(); + expect(window.localStorage.getItem(storageKey)).toBeNull(); +}); + +test("creates bearer sessions with tokens from bearer login responses", () => { + expect( + sessionFromLogin( + { + session_id: "ses_live", + account: { + id: "acct_live", + username: "live-user", + role: "user", + }, + token: "bearer-token", + created_at: "2026-06-01T00:00:00Z", + expires_at: "2026-06-01T01:00:00Z", + }, + "live", + "bearer", + ), + ).toMatchObject({ + sessionId: "ses_live", + token: "bearer-token", + mode: "live", + authMode: "bearer", + }); +}); + +test("creates cookie sessions without bearer tokens", () => { + const cookieSession = sessionFromLogin( + { + session_id: "ses_cookie", + account: { + id: "acct_cookie", + username: "cookie-user", + role: "user", + }, + created_at: "2026-06-01T00:00:00Z", + expires_at: "2026-06-01T01:00:00Z", + }, + "live", + "cookie", + ); + + expect(cookieSession).toMatchObject({ + sessionId: "ses_cookie", + mode: "live", + authMode: "cookie", + }); + expect("token" in cookieSession).toBe(false); +}); diff --git a/src/auth/session.ts b/src/auth/session.ts index 7d7d47a..af68e2b 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,7 +1,9 @@ import { sessionSchema, + type AuthMode, type LoginResponse, type Session, + type WebLoginResponse, } from "../api/schemas"; const storageKey = "proofline.web-client.session"; @@ -18,17 +20,28 @@ function hasUsableExpiration(session: Session): boolean { } export function sessionFromLogin( - response: LoginResponse, + response: LoginResponse | WebLoginResponse, mode: Session["mode"], + authMode: AuthMode, ): Session { - return { + const session = { sessionId: response.session_id, account: response.account, - token: response.token, createdAt: response.created_at, expiresAt: response.expires_at, mode, + authMode, }; + if (authMode === "bearer") { + if (!("token" in response)) { + throw new Error("bearer login response did not include a token"); + } + return { + ...session, + token: response.token, + }; + } + return session; } export function loadSession(): Session | null { diff --git a/src/auth/use-auth.ts b/src/auth/use-auth.ts index f050153..02289db 100644 --- a/src/auth/use-auth.ts +++ b/src/auth/use-auth.ts @@ -9,6 +9,8 @@ import { } from "react"; import { createProoflineApiClient, + defaultProoflineAuthMode, + defaultProoflineClientMode, type ProoflineApiClient, } from "../api/client"; import { ApiError, safeErrorMessage } from "../api/errors"; @@ -37,22 +39,41 @@ type AuthContextValue = { const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(() => loadSession()); - const token = session?.token ?? null; + const clientMode = defaultProoflineClientMode(); + const authMode = defaultProoflineAuthMode(clientMode); + const [session, setSession] = useState(() => { + const loadedSession = loadSession(); + if ( + loadedSession && + (loadedSession.mode !== clientMode || loadedSession.authMode !== authMode) + ) { + clearSession(); + return null; + } + return loadedSession; + }); + const token = + session?.authMode === "bearer" && session.token ? session.token : null; const apiClient = useMemo( () => createProoflineApiClient({ + mode: clientMode, + authMode, getToken: () => token, }), - [token], + [authMode, clientMode, token], ); const login = useCallback( async (credentials: { username: string; password: string }) => { try { const response = await apiClient.login(credentials); - const nextSession = sessionFromLogin(response, apiClient.mode); + const nextSession = sessionFromLogin( + response, + apiClient.mode, + apiClient.authMode, + ); saveSession(nextSession); setSession(nextSession); return { ok: true as const }; @@ -78,6 +99,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { await apiClient.logout(); } finally { + apiClient.clearAuthenticationState(); clearSession(); setSession(null); }