diff --git a/documentation/authentication-system.md b/documentation/authentication-system.md index 7bb3b6f..0390c38 100644 --- a/documentation/authentication-system.md +++ b/documentation/authentication-system.md @@ -264,9 +264,11 @@ const authLimiter = createRateLimiter({ Nginx and DNS-only Apache configurations replace supplied chains with their socket client address. -Bearer tokens are stored in `sessionStorage` only. Older `localStorage` token -and user entries are removed during login and logout so credentials do not -survive closing the browser. +The login form's **Remember** option controls bearer-token storage. It is +checked by default and stores the token in `localStorage`, which keeps private +presentations available across tabs and browser restarts. When unchecked, the +token stays in `sessionStorage` and lasts only for the current tab. Login clears +the inactive storage location, and logout clears both. ## User Model @@ -389,8 +391,8 @@ builder.addCase(loginUser.fulfilled, (state, action) => { const token = action.payload; const user = decodeAuthToken(token); - // Session-scoped storage also removes legacy localStorage credentials - storeAuthSession(token, user); + // Remembered sessions use localStorage; other sessions stay in this tab. + storeAuthSession(token, user, action.meta.arg.remember); // Set default header axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; @@ -568,7 +570,7 @@ const isPresignedS3Url = (url: string): boolean => { | Expiration | 6 hours | | Secret | Environment variable `SECRET_KEY` | | Transmission | Bearer token in Authorization header | -| Storage | `sessionStorage` through `lib/authStorage.ts` | +| Storage | `localStorage` when Remember is checked; otherwise `sessionStorage` | ### Verification Tokens @@ -770,6 +772,6 @@ res.status(429).send({ - Ensure OAuth scopes are properly configured **Token not persisting after refresh:** -- Check `sessionStorage`; tokens intentionally do not survive closing the browser +- Check `localStorage` for remembered login or `sessionStorage` for tab-only login - Verify axios interceptors are properly attached - Check for errors in browser console diff --git a/documentation/project-architecture.md b/documentation/project-architecture.md index 8f13482..f10e7d1 100644 --- a/documentation/project-architecture.md +++ b/documentation/project-architecture.md @@ -677,7 +677,7 @@ All entities include: ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Store Token │<────│ Return JWT │<────│ Generate JWT │ -│ (sessionStorage)│ │ │ │ (6h expiry) │ +│ (Remember/local) │ │ │ │ (6h expiry) │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ ▼ diff --git a/documentation/security-assessment-2026-07-22.md b/documentation/security-assessment-2026-07-22.md index 6207e94..e3776b1 100644 --- a/documentation/security-assessment-2026-07-22.md +++ b/documentation/security-assessment-2026-07-22.md @@ -22,10 +22,11 @@ belong to that presentation. Current application risk is **moderate**. `npm audit --omit=dev` reports no known production dependency advisories. Accepted operational risks include -credential delivery through the tracked backend `.env` file and Cloudflare's -public wildcard CORS response headers. Bearer tokens remain browser-readable -during an active tab session, but are no longer kept across browser sessions. -The new response policy further reduces that exposure. +credential delivery through the tracked backend `.env` file, Cloudflare's +public wildcard CORS response headers, and browser-readable bearer tokens. +The login form remembers tokens by default so internal users can open private +presentations across tabs and browser restarts. CSP reduces the exposure but +does not make `localStorage` inaccessible to same-origin scripts. ## Findings and Current Status @@ -35,8 +36,8 @@ The new response policy further reduces that exposure. | Public presign accepted arbitrary storage keys | Non-staff calls require production runtime context, presentation access, and project-owned asset references | No context `401`; private anonymous context `403` | | Forwarded IP spoofing bypassed rate limits | Express now trusts only the loopback proxy hop, so attacker-controlled addresses on the left of the tunnel's forwarded chain are ignored | Live old build gave separate buckets (`9`, `9`); local fixed build gave one bucket ending in `429`; public retest required after deployment | | Reflected credentialed CORS | The backend now uses a configured UI allowlist without credentials, but Cloudflare's public wildcard CORS headers will remain by owner decision | Direct backend fix verified locally; live public responses still return wildcard CORS with credentials | -| Frontend lacked browser security headers | Added CSP, referrer policy, MIME sniffing protection, production HSTS, and removed the Next.js identifying header; development HTTP sources are scheme-based and iframe providers use one shared frontend allowlist | Development headers verified; production excludes `http:` and includes HSTS | -| Bearer tokens persisted in local storage | Authentication is session-storage only; old local-storage credentials are removed on application startup, login, and logout | Static scan shows no remaining token reads or writes outside the storage helper | +| Frontend lacked browser security headers | Added CSP, referrer policy, MIME sniffing protection, production HSTS, and removed the Next.js identifying header; development HTTP sources are scheme-based, iframe providers use one shared frontend allowlist, and Cloudflare Web Analytics is allowed from its fixed script origin | Development headers verified; production excludes `http:` and includes HSTS | +| Bearer tokens persisted in local storage | Accepted for internal-use convenience when Remember is checked; unchecked login remains tab-scoped, and logout clears both stores | Browser tests cover remembered and tab-only login | | Static JWT and seed-password fallbacks | JWT secret is required and at least 32 characters; seed passwords are required only when seed users must be created | Backend starts with the VM-provided secret; known fallback administrator login remains inactive | | Health endpoint exposed environment and uptime | Public health response now contains only status, timestamp, and database state | Response verified locally | | Known production dependency advisories | Sharp is overridden to `0.35.3`; Express's nested body-parser is overridden to `1.20.6` | Both production audits report zero vulnerabilities | @@ -72,11 +73,22 @@ mainly unauthenticated public API data, which other websites can call and read. Revisit this decision before switching to cookie authentication, exposing sensitive anonymous endpoints, or allowing broader external platform use. +## Accepted Risk: Remembered Browser Tokens + +The login form remembers bearer tokens in `localStorage` by default. This lets +internal users open private presentations in new tabs without signing in each +time. Unchecking Remember uses `sessionStorage` instead, and logout clears both +locations. + +Any script running on the application origin can read these tokens. Keep the +CSP script allowlist narrow and revisit HttpOnly cookies if the platform gains +more external users or stores more sensitive data. + ## Choices Kept Deliberately Simple - Authentication remains bearer-token based; migration to HttpOnly cookies and - CSRF tokens was not introduced. Session-only storage plus CSP provides a - proportional improvement without rewriting authentication. + CSRF tokens was not introduced. Remembered login is an accepted convenience + tradeoff for the current internal-use deployment. - Rate limiting remains in memory because the deployment is a single backend process. Redis is not required for the current topology. - Swagger remains public, but it now receives Helmet security headers. It does @@ -90,7 +102,7 @@ sensitive anonymous endpoints, or allowing broader external platform use. - Backend strict typecheck and lint: passed - Frontend strict typecheck and lint: passed - Backend tests: 82 unit, 14 database integration, and 3 HTTP E2E tests passed -- Frontend tests: 316 unit and 14 Playwright browser tests passed +- Frontend tests: 316 unit and 15 Playwright browser tests passed - Frontend production build: passed - Backend and frontend `npm audit --omit=dev`: zero vulnerabilities - Public-role database hardening audit: passed diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md index 26d51ec..f504115 100644 --- a/frontend/docs/frontend-architecture.md +++ b/frontend/docs/frontend-architecture.md @@ -1156,7 +1156,9 @@ const nextConfig = { The CSP permits generic `http:` image, media, API, and WebSocket sources only in development, so local ports do not need to be duplicated in the header configuration. Production remains restricted to same-origin and HTTPS/WSS. -Trusted iframe providers are defined once in +Cloudflare Web Analytics may load scripts only from +`static.cloudflareinsights.com`; its beacon uses the existing HTTPS connection +policy. Trusted iframe providers are defined once in `src/config/embedDomains.json`; both CSP `frame-src` generation and frontend embed URL validation consume that list. diff --git a/frontend/docs/stores-module.md b/frontend/docs/stores-module.md index 11d5fb6..c1a9a3c 100644 --- a/frontend/docs/stores-module.md +++ b/frontend/docs/stores-module.md @@ -940,7 +940,7 @@ Redux is for client/app state. Use Redux slices for: | State Type | Example | Why Redux | |------------|---------|-----------| -| **Authentication** | Current user, JWT token | App-wide; credentials use sessionStorage | +| **Authentication** | Current user, JWT token | App-wide; Remember uses localStorage, otherwise sessionStorage | | **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app | | **Layout/App UI** | Sidebar, theme, app preferences | Shared client state | | **Constructor UI State** | Selected elements, canvas state | Shared builder interactions | diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index d5ce6e4..4b7f018 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -16,15 +16,12 @@ const embedFrameSources = allowedEmbedDomains.flatMap((domain) => [ ]); const contentSecurityPolicy = [ "default-src 'self'", - `script-src 'self' 'unsafe-inline'${isDevelopment ? " 'unsafe-eval'" : ''}`, + `script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com${isDevelopment ? " 'unsafe-eval'" : ''}`, "style-src 'self' 'unsafe-inline'", `img-src 'self' data: blob: https:${developmentHttpSources}`, `media-src 'self' blob: https:${developmentHttpSources}`, `connect-src 'self' https: wss:${isDevelopment ? ' http: ws:' : ''}`, - [ - "frame-src 'self'", - ...embedFrameSources, - ].join(' '), + ["frame-src 'self'", ...embedFrameSources].join(' '), "font-src 'self' data:", "worker-src 'self' blob:", "object-src 'none'", diff --git a/frontend/src/lib/authStorage.ts b/frontend/src/lib/authStorage.ts index d23182d..379186f 100644 --- a/frontend/src/lib/authStorage.ts +++ b/frontend/src/lib/authStorage.ts @@ -6,26 +6,28 @@ const canUseBrowserStorage = (): boolean => typeof window !== 'undefined'; export const getStoredAuthToken = (): string | null => { if (!canUseBrowserStorage()) return null; - // Clear credentials left by older builds as soon as the application loads. - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); - return sessionStorage.getItem(TOKEN_KEY); + return sessionStorage.getItem(TOKEN_KEY) ?? localStorage.getItem(TOKEN_KEY); }; -export const storeAuthSession = (token: string, user?: unknown): void => { +export const storeAuthSession = ( + token: string, + user: unknown, + remember: boolean, +): void => { if (!canUseBrowserStorage()) return; - sessionStorage.setItem(TOKEN_KEY, token); + const activeStorage = remember ? localStorage : sessionStorage; + const inactiveStorage = remember ? sessionStorage : localStorage; + + activeStorage.setItem(TOKEN_KEY, token); if (user) { - sessionStorage.setItem(USER_KEY, JSON.stringify(user)); + activeStorage.setItem(USER_KEY, JSON.stringify(user)); } else { - sessionStorage.removeItem(USER_KEY); + activeStorage.removeItem(USER_KEY); } - // Remove tokens written by older builds. Authentication is intentionally - // session-scoped so closing the browser clears bearer credentials. - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); + inactiveStorage.removeItem(TOKEN_KEY); + inactiveStorage.removeItem(USER_KEY); }; export const clearAuthSession = (): void => { diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index d791bb6..66363c5 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -82,8 +82,7 @@ export default function Login() { }; const handleSubmit = async (value: typeof initialValues) => { - const { remember, ...rest } = value; - await dispatch(loginUser(rest)); + await dispatch(loginUser(value)); }; return ( diff --git a/frontend/src/stores/authSlice.ts b/frontend/src/stores/authSlice.ts index a3342a8..6e8fdf3 100644 --- a/frontend/src/stores/authSlice.ts +++ b/frontend/src/stores/authSlice.ts @@ -16,12 +16,19 @@ const initialState: AuthState = { }, }; +interface LoginCredentials { + email: string; + password: string; + remember: boolean; +} + export const resetAction = createAction('auth/passwordReset/reset'); export const loginUser = createAsyncThunk( 'auth/loginUser', - async (creds: Record, { rejectWithValue }) => { + async (credentials: LoginCredentials, { rejectWithValue }) => { try { + const { remember: _remember, ...creds } = credentials; const response = await axios.post('auth/signin/local', creds); return response.data; } catch (error) { @@ -81,7 +88,7 @@ export const authSlice = createSlice({ state.errorMessage = ''; state.isFetching = false; state.token = token; - storeAuthSession(token, user); + storeAuthSession(token, user, action.meta.arg.remember); axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; }); diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts index 9ff138e..2872eff 100644 --- a/frontend/tests/e2e/auth.spec.ts +++ b/frontend/tests/e2e/auth.spec.ts @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; -import { collectConsoleFailures, mockFrontendApi, testUser } from './fixtures'; +import { + collectConsoleFailures, + mockFrontendApi, + testToken, + testUser, +} from './fixtures'; test.beforeEach(async ({ page }) => { await mockFrontendApi(page); @@ -32,5 +37,33 @@ test('local login reaches authenticated dashboard shell', async ({ page }) => { await expect( page.getByRole('link', { name: /Projects/ }).first(), ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => ({ + local: window.localStorage.getItem('token'), + session: window.sessionStorage.getItem('token'), + })), + ) + .toEqual({ local: testToken, session: null }); consoleFailures.assertClean(); }); + +test('login without Remember keeps credentials in the current tab', async ({ + page, +}) => { + await page.goto('/login'); + await page.locator('input[name="email"]').fill(testUser.email); + await page.locator('input[name="password"]').fill('not-used-with-api-mock'); + await page.locator('input[name="remember"]').uncheck({ force: true }); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect(page).toHaveURL(/\/dashboard/); + await expect + .poll(() => + page.evaluate(() => ({ + local: window.localStorage.getItem('token'), + session: window.sessionStorage.getItem('token'), + })), + ) + .toEqual({ local: null, session: testToken }); +}); diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index b39c05d..882dc92 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -433,9 +433,7 @@ export async function authenticate(page: Page, user = testUser) { await page.addInitScript( ({ token, user }) => { window.localStorage.setItem('token', token); - window.sessionStorage.setItem('token', token); window.localStorage.setItem('user', JSON.stringify(user)); - window.sessionStorage.setItem('user', JSON.stringify(user)); }, { token: testToken, user }, );