From d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e Mon Sep 17 00:00:00 2001 From: codex-bot Date: Fri, 23 Oct 2026 08:34:39 +0300 Subject: [PATCH] fix: persist refresh cookie across browser restarts (WealthLedger/backend#123) --- .nvmrc | 2 +- Dockerfile | 2 +- eslint.config.mjs | 2 +- package-lock.json | 2 +- package.json | 2 +- src/modules/auth/application/auth.service.ts | 2 +- .../auth/presentation/auth.controller.ts | 3 ++- test/app.e2e-spec.ts | 17 ++++++++++++----- 8 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.nvmrc b/.nvmrc index df6ae33..ce62013 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.21.0 +24.22.1 diff --git a/Dockerfile b/Dockerfile index 9dd0866..3ca3d76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24.21.0-bookworm-slim AS base +FROM node:24.22.1-bookworm-slim AS base WORKDIR /app FROM base AS dependencies diff --git a/eslint.config.mjs b/eslint.config.mjs index 9fde38f..e453da7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,7 +37,7 @@ export default defineConfig( linterOptions: { reportUnusedDisableDirectives: 'error' }, settings: { 'import-x/resolver-next': [createTypeScriptImportResolver()], - 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' }, + 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' }, }, rules: { 'curly': ['error', 'multi-line'], diff --git a/package-lock.json b/package-lock.json index 9289b44..cbae37b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,7 @@ "typescript-eslint": "8.70.1" }, "engines": { - "node": "v24.21.0" + "node": "v24.22.1" } }, "node_modules/@angular-devkit/schematics": { diff --git a/package.json b/package.json index b8ea3a8..936dc91 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "private": true, "license": "UNLICENSED", "engines": { - "node": "v24.21.0" + "node": "v24.22.1" }, "scripts": { "build": "prisma generate && nest build", diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 6b0a2fa..f19cbc2 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -17,7 +17,7 @@ import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; const EXPECTED_ROTATED_SESSIONS = 1; const PASSWORD_HASH_ROUNDS = 12; -const REFRESH_TTL_MS = 604_800_000; +export const REFRESH_TTL_MS = 604_800_000; const ACCESS_TTL = '15m'; const REFRESH_TTL = '7d'; diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 1fdbed1..bfa4c5b 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -22,7 +22,7 @@ import { import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { HttpErrorCode } from '../../../common/error-code'; -import { AuthService } from '../application/auth.service'; +import { AuthService, REFRESH_TTL_MS } from '../application/auth.service'; import { AccessPayload, AuthenticationResult } from '../application/auth.types'; import { LoginDto, RegisterDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -134,6 +134,7 @@ export class AuthController { sameSite: 'lax' as const, secure: process.env['NODE_ENV'] !== 'development', path: COOKIE_PATH, + maxAge: REFRESH_TTL_MS, }; } } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 4f6b5e8..a2c9204 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -131,6 +131,9 @@ describe('Authentication (e2e)', () => { .filter((attribute) => !attribute.startsWith('Expires=')) .sort((a, b) => a.localeCompare(b)); const host = ['HttpOnly', 'Path=/api/v1/auth', 'SameSite=Lax']; + // Persistent for the Session lifetime (7 days), otherwise the browser drops it on restart. + const persistent = [...host, 'Max-Age=604800']; + const sorted = (items: string[]) => [...items].sort((a, b) => a.localeCompare(b)); const login = async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register') @@ -150,7 +153,7 @@ describe('Authentication (e2e)', () => { } }; - it('issues, rotates and clears it HttpOnly, Lax, path-scoped, host-only and Secure in production', async () => { + it('issues and rotates it persistent (Max-Age) HttpOnly, Lax, path-scoped, host-only and Secure in production, and clears it', async () => { await inEnvironment('production', async () => { const issued = await login(); const rotated = await request(app.getHttpServer()) @@ -162,16 +165,20 @@ describe('Authentication (e2e)', () => { .set('Cookie', rotated.headers['set-cookie'][0]) .expect(204); - // Exact set: any added Domain/Max-Age or a dropped flag fails. - for (const cookie of [issued, rotated.headers['set-cookie'][0], cleared.headers['set-cookie'][0]]) { - expect(attributes(cookie)).toEqual([...host, 'Secure'].sort((a, b) => a.localeCompare(b))); + // Exact set: any added Domain, changed Max-Age or dropped flag fails. + for (const cookie of [issued, rotated.headers['set-cookie'][0]]) { + expect(attributes(cookie)).toEqual(sorted([...persistent, 'Secure'])); } + // Clearing must not carry Max-Age; Express expires it with a past Expires. + const clearedCookie = cleared.headers['set-cookie'][0]; + expect(attributes(clearedCookie)).toEqual(sorted([...host, 'Secure'])); + expect(clearedCookie).toContain('Expires=Thu, 01 Jan 1970'); }); }); it('drops only Secure in development and keeps every other attribute', async () => { await inEnvironment('development', async () => { - expect(attributes(await login())).toEqual(host); + expect(attributes(await login())).toEqual(sorted(persistent)); }); }); });