Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,29 @@ 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.
*/
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',
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading