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
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

View File

@ -677,7 +677,7 @@ All entities include:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 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
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

View File

@ -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.

View File

@ -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 |

View File

@ -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'",

View File

@ -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 => {

View File

@ -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 (

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 loginUser = createAsyncThunk(
'auth/loginUser',
async (creds: Record<string, string>, { 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;
});

View File

@ -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 });
});

View File

@ -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 },
);