From 14644a014f0536b53a9712f70e56bd18fb3e6f29 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Thu, 25 Jun 2026 00:48:51 +0200 Subject: [PATCH] fix(auth): allow refresh cookie over plain-HTTP deployments via COOKIE_SECURE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh cookie was marked Secure whenever NODE_ENV=production. A Secure cookie is silently dropped by the browser when the app is served over plain HTTP on a non-localhost host (e.g. http://mini7:8080), so the in-memory access token worked while navigating but every full page reload — which can only restore the session by exchanging the refresh cookie at /api/auth/refresh — found no cookie and bounced the user to /login. Add a COOKIE_SECURE override (secure-by-default: unset falls back to NODE_ENV==='production'). Operators serving over plain HTTP on a trusted network set COOKIE_SECURE=false; HTTPS deployments are unaffected. Documented in .env.example and wired through docker-compose.yml. --- .env.example | 6 ++++ apps/server/src/auth/auth.controller.spec.ts | 34 ++++++++++++++++++++ apps/server/src/auth/auth.controller.ts | 17 +++++++++- docker-compose.yml | 4 +++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 0cf5ce2..2ed543b 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,12 @@ LOG_LEVEL=debug # Set to true/1/yes when behind a reverse proxy (e.g. Caddy) so client IP is read from X-Forwarded-For # TRUST_PROXY=true +# Refresh-cookie Secure flag. Defaults to true when NODE_ENV=production. A Secure cookie is dropped by +# the browser over plain HTTP on a non-localhost host, which logs users out on every page reload (the +# reload restores the session only by exchanging this cookie). If you serve the app over plain HTTP on +# a trusted network (e.g. a LAN-only http://host:port), set this to false. Prefer HTTPS where possible. +# COOKIE_SECURE=false + # CORS (comma-separated origins; keep strict — do not use * with credentials) CORS_ORIGINS=http://localhost:5173 diff --git a/apps/server/src/auth/auth.controller.spec.ts b/apps/server/src/auth/auth.controller.spec.ts index 8c46662..19bd34d 100644 --- a/apps/server/src/auth/auth.controller.spec.ts +++ b/apps/server/src/auth/auth.controller.spec.ts @@ -257,6 +257,40 @@ describe('AuthController', () => { await expect(controller.refresh(req, mockReply)).rejects.toThrow('Missing refresh token'); expect(mockAuthService.refreshTokens).not.toHaveBeenCalled(); }); + + // The Secure flag governs whether a plain-HTTP (e.g. LAN) deployment can keep a session across + // reloads: a Secure cookie is dropped over http, so the reload-restore via /api/auth/refresh fails. + describe('Secure cookie flag', () => { + const req = { + headers: {}, + cookies: { refresh_token: 'rt-from-cookie' }, + } as unknown as FastifyRequest; + const original = process.env['COOKIE_SECURE']; + afterEach(() => { + if (original === undefined) delete process.env['COOKIE_SECURE']; + else process.env['COOKIE_SECURE'] = original; + }); + + it('marks the cookie Secure=false when COOKIE_SECURE=false (plain-HTTP deployment)', async () => { + process.env['COOKIE_SECURE'] = 'false'; + await controller.refresh(req, mockReply); + expect(mockReply.setCookie).toHaveBeenCalledWith( + 'refresh_token', + 'rt2', + expect.objectContaining({ secure: false }), + ); + }); + + it('marks the cookie Secure=true when COOKIE_SECURE=true', async () => { + process.env['COOKIE_SECURE'] = 'true'; + await controller.refresh(req, mockReply); + expect(mockReply.setCookie).toHaveBeenCalledWith( + 'refresh_token', + 'rt2', + expect.objectContaining({ secure: true }), + ); + }); + }); }); describe('logout', () => { diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts index 94469f2..736a62e 100644 --- a/apps/server/src/auth/auth.controller.ts +++ b/apps/server/src/auth/auth.controller.ts @@ -29,6 +29,21 @@ import type { FastifyRequest, FastifyReply } from 'fastify'; /** httpOnly cookie that carries the refresh token; never exposed to browser JavaScript. */ const REFRESH_COOKIE = 'refresh_token'; +/** + * Whether the refresh cookie is marked `Secure` (sent only over HTTPS). Defaults to true in + * production so a real deployment never ships the refresh token over plaintext. A `Secure` cookie + * is silently dropped by the browser when the app is served over plain HTTP on a non-localhost host, + * which makes every page reload log the user out (the reload can only restore the session by + * exchanging this cookie). Operators serving over plain HTTP on a trusted network (e.g. a LAN-only + * http://host:port) must therefore set `COOKIE_SECURE=false`; ideally, serve the app over HTTPS. + */ +function isCookieSecure(): boolean { + const override = process.env['COOKIE_SECURE']; + if (override === 'true') return true; + if (override === 'false') return false; + return process.env['NODE_ENV'] === 'production'; +} + /** * Cookie attributes shared by set and clear. Browsers only delete a cookie when the clear call's * attributes match those it was set with, so both paths must use the same values. @@ -36,7 +51,7 @@ const REFRESH_COOKIE = 'refresh_token'; function refreshCookieAttrs() { return { httpOnly: true, - secure: process.env['NODE_ENV'] === 'production', + secure: isCookieSecure(), sameSite: 'lax' as const, // Scope to the auth routes that consume it (refresh, logout), so it is not sent on every request. path: '/api/auth', diff --git a/docker-compose.yml b/docker-compose.yml index b3a667a..8fb97f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,10 @@ services: JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d} ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY to a strong random value (openssl rand -base64 48)} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080} + # Defaults to Secure refresh cookies in production. Set COOKIE_SECURE=false when serving over + # plain HTTP on a trusted network (e.g. http://host:8080), otherwise the browser drops the Secure + # cookie and users are logged out on every page reload. Prefer serving over HTTPS. + COOKIE_SECURE: ${COOKIE_SECURE:-} REDIS_URL: redis://redis:6379 WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Bunker46} WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}