fixed auth tokens persistance

This commit is contained in:
Dmitri 2026-07-22 15:39:49 +02:00
parent 9b9948f2ad
commit 53c683023e
11 changed files with 95 additions and 43 deletions

View File

@ -264,9 +264,11 @@ const authLimiter = createRateLimiter({
Nginx and DNS-only Apache configurations replace supplied chains with their Nginx and DNS-only Apache configurations replace supplied chains with their
socket client address. socket client address.
Bearer tokens are stored in `sessionStorage` only. Older `localStorage` token The login form's **Remember** option controls bearer-token storage. It is
and user entries are removed during login and logout so credentials do not checked by default and stores the token in `localStorage`, which keeps private
survive closing the browser. 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 ## User Model
@ -389,8 +391,8 @@ builder.addCase(loginUser.fulfilled, (state, action) => {
const token = action.payload; const token = action.payload;
const user = decodeAuthToken(token); const user = decodeAuthToken(token);
// Session-scoped storage also removes legacy localStorage credentials // Remembered sessions use localStorage; other sessions stay in this tab.
storeAuthSession(token, user); storeAuthSession(token, user, action.meta.arg.remember);
// Set default header // Set default header
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
@ -568,7 +570,7 @@ const isPresignedS3Url = (url: string): boolean => {
| Expiration | 6 hours | | Expiration | 6 hours |
| Secret | Environment variable `SECRET_KEY` | | Secret | Environment variable `SECRET_KEY` |
| Transmission | Bearer token in Authorization header | | Transmission | Bearer token in Authorization header |
| Storage | `sessionStorage` through `lib/authStorage.ts` | | Storage | `localStorage` when Remember is checked; otherwise `sessionStorage` |
### Verification Tokens ### Verification Tokens
@ -770,6 +772,6 @@ res.status(429).send({
- Ensure OAuth scopes are properly configured - Ensure OAuth scopes are properly configured
**Token not persisting after refresh:** **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 - Verify axios interceptors are properly attached
- Check for errors in browser console - Check for errors in browser console

View File

@ -677,7 +677,7 @@ All entities include:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Store Token │<────│ Return JWT │<────│ Generate JWT │ │ Store Token │<────│ Return JWT │<────│ Generate JWT │
(sessionStorage)│ │ │ │ (6h expiry) │ (Remember/local) │ │ │ │ (6h expiry) │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘

View File

@ -22,10 +22,11 @@ belong to that presentation.
Current application risk is **moderate**. `npm audit --omit=dev` reports no Current application risk is **moderate**. `npm audit --omit=dev` reports no
known production dependency advisories. Accepted operational risks include known production dependency advisories. Accepted operational risks include
credential delivery through the tracked backend `.env` file and Cloudflare's credential delivery through the tracked backend `.env` file, Cloudflare's
public wildcard CORS response headers. Bearer tokens remain browser-readable public wildcard CORS response headers, and browser-readable bearer tokens.
during an active tab session, but are no longer kept across browser sessions. The login form remembers tokens by default so internal users can open private
The new response policy further reduces that exposure. presentations across tabs and browser restarts. CSP reduces the exposure but
does not make `localStorage` inaccessible to same-origin scripts.
## Findings and Current Status ## 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` | | 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 | | 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 | | 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 | | 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 | 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 | | 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 | | 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 | | 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 | | 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 Revisit this decision before switching to cookie authentication, exposing
sensitive anonymous endpoints, or allowing broader external platform use. 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 ## Choices Kept Deliberately Simple
- Authentication remains bearer-token based; migration to HttpOnly cookies and - Authentication remains bearer-token based; migration to HttpOnly cookies and
CSRF tokens was not introduced. Session-only storage plus CSP provides a CSRF tokens was not introduced. Remembered login is an accepted convenience
proportional improvement without rewriting authentication. tradeoff for the current internal-use deployment.
- Rate limiting remains in memory because the deployment is a single backend - Rate limiting remains in memory because the deployment is a single backend
process. Redis is not required for the current topology. process. Redis is not required for the current topology.
- Swagger remains public, but it now receives Helmet security headers. It does - 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 - Backend strict typecheck and lint: passed
- Frontend strict typecheck and lint: passed - Frontend strict typecheck and lint: passed
- Backend tests: 82 unit, 14 database integration, and 3 HTTP E2E tests 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 - Frontend production build: passed
- Backend and frontend `npm audit --omit=dev`: zero vulnerabilities - Backend and frontend `npm audit --omit=dev`: zero vulnerabilities
- Public-role database hardening audit: passed - Public-role database hardening audit: passed

View File

@ -1156,7 +1156,9 @@ const nextConfig = {
The CSP permits generic `http:` image, media, API, and WebSocket sources only 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 in development, so local ports do not need to be duplicated in the header
configuration. Production remains restricted to same-origin and HTTPS/WSS. 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 `src/config/embedDomains.json`; both CSP `frame-src` generation and frontend
embed URL validation consume that list. embed URL validation consume that list.

View File

@ -940,7 +940,7 @@ Redux is for client/app state. Use Redux slices for:
| State Type | Example | Why Redux | | 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 | | **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app |
| **Layout/App UI** | Sidebar, theme, app preferences | Shared client state | | **Layout/App UI** | Sidebar, theme, app preferences | Shared client state |
| **Constructor UI State** | Selected elements, canvas state | Shared builder interactions | | **Constructor UI State** | Selected elements, canvas state | Shared builder interactions |

View File

@ -16,15 +16,12 @@ const embedFrameSources = allowedEmbedDomains.flatMap((domain) => [
]); ]);
const contentSecurityPolicy = [ const contentSecurityPolicy = [
"default-src 'self'", "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'", "style-src 'self' 'unsafe-inline'",
`img-src 'self' data: blob: https:${developmentHttpSources}`, `img-src 'self' data: blob: https:${developmentHttpSources}`,
`media-src 'self' blob: https:${developmentHttpSources}`, `media-src 'self' blob: https:${developmentHttpSources}`,
`connect-src 'self' https: wss:${isDevelopment ? ' http: ws:' : ''}`, `connect-src 'self' https: wss:${isDevelopment ? ' http: ws:' : ''}`,
[ ["frame-src 'self'", ...embedFrameSources].join(' '),
"frame-src 'self'",
...embedFrameSources,
].join(' '),
"font-src 'self' data:", "font-src 'self' data:",
"worker-src 'self' blob:", "worker-src 'self' blob:",
"object-src 'none'", "object-src 'none'",

View File

@ -6,26 +6,28 @@ const canUseBrowserStorage = (): boolean => typeof window !== 'undefined';
export const getStoredAuthToken = (): string | null => { export const getStoredAuthToken = (): string | null => {
if (!canUseBrowserStorage()) return null; if (!canUseBrowserStorage()) return null;
// Clear credentials left by older builds as soon as the application loads. return sessionStorage.getItem(TOKEN_KEY) ?? localStorage.getItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
return sessionStorage.getItem(TOKEN_KEY);
}; };
export const storeAuthSession = (token: string, user?: unknown): void => { export const storeAuthSession = (
token: string,
user: unknown,
remember: boolean,
): void => {
if (!canUseBrowserStorage()) return; 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) { if (user) {
sessionStorage.setItem(USER_KEY, JSON.stringify(user)); activeStorage.setItem(USER_KEY, JSON.stringify(user));
} else { } else {
sessionStorage.removeItem(USER_KEY); activeStorage.removeItem(USER_KEY);
} }
// Remove tokens written by older builds. Authentication is intentionally inactiveStorage.removeItem(TOKEN_KEY);
// session-scoped so closing the browser clears bearer credentials. inactiveStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}; };
export const clearAuthSession = (): void => { export const clearAuthSession = (): void => {

View File

@ -82,8 +82,7 @@ export default function Login() {
}; };
const handleSubmit = async (value: typeof initialValues) => { const handleSubmit = async (value: typeof initialValues) => {
const { remember, ...rest } = value; await dispatch(loginUser(value));
await dispatch(loginUser(rest));
}; };
return ( return (

View File

@ -16,12 +16,19 @@ const initialState: AuthState = {
}, },
}; };
interface LoginCredentials {
email: string;
password: string;
remember: boolean;
}
export const resetAction = createAction('auth/passwordReset/reset'); export const resetAction = createAction('auth/passwordReset/reset');
export const loginUser = createAsyncThunk( export const loginUser = createAsyncThunk(
'auth/loginUser', 'auth/loginUser',
async (creds: Record<string, string>, { rejectWithValue }) => { async (credentials: LoginCredentials, { rejectWithValue }) => {
try { try {
const { remember: _remember, ...creds } = credentials;
const response = await axios.post('auth/signin/local', creds); const response = await axios.post('auth/signin/local', creds);
return response.data; return response.data;
} catch (error) { } catch (error) {
@ -81,7 +88,7 @@ export const authSlice = createSlice({
state.errorMessage = ''; state.errorMessage = '';
state.isFetching = false; state.isFetching = false;
state.token = token; state.token = token;
storeAuthSession(token, user); storeAuthSession(token, user, action.meta.arg.remember);
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
}); });

View File

@ -1,5 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { collectConsoleFailures, mockFrontendApi, testUser } from './fixtures'; import {
collectConsoleFailures,
mockFrontendApi,
testToken,
testUser,
} from './fixtures';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await mockFrontendApi(page); await mockFrontendApi(page);
@ -32,5 +37,33 @@ test('local login reaches authenticated dashboard shell', async ({ page }) => {
await expect( await expect(
page.getByRole('link', { name: /Projects/ }).first(), page.getByRole('link', { name: /Projects/ }).first(),
).toBeVisible(); ).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => ({
local: window.localStorage.getItem('token'),
session: window.sessionStorage.getItem('token'),
})),
)
.toEqual({ local: testToken, session: null });
consoleFailures.assertClean(); 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 });
});

View File

@ -433,9 +433,7 @@ export async function authenticate(page: Page, user = testUser) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
window.localStorage.setItem('token', token); window.localStorage.setItem('token', token);
window.sessionStorage.setItem('token', token);
window.localStorage.setItem('user', JSON.stringify(user)); window.localStorage.setItem('user', JSON.stringify(user));
window.sessionStorage.setItem('user', JSON.stringify(user));
}, },
{ token: testToken, user }, { token: testToken, user },
); );