diff --git a/.env.example b/.env.example index e1608ec907..3d4e372338 100644 --- a/.env.example +++ b/.env.example @@ -488,6 +488,10 @@ ALLOW_UNVERIFIED_EMAIL_LOGIN=true SESSION_EXPIRY=1000 * 60 * 15 REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7 +# Overrides the Secure attribute for session/auth cookies when set to true or false; +# leave unset to use the default NODE_ENV/DOMAIN_SERVER heuristic. +# Set to false only for HTTP-only deployments where browsers drop Secure cookies. +# SESSION_COOKIE_SECURE=false JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418 diff --git a/packages/api/src/oauth/csrf.spec.ts b/packages/api/src/oauth/csrf.spec.ts index b56f1fd38f..1199dea297 100644 --- a/packages/api/src/oauth/csrf.spec.ts +++ b/packages/api/src/oauth/csrf.spec.ts @@ -5,6 +5,7 @@ describe('shouldUseSecureCookie', () => { beforeEach(() => { process.env = { ...originalEnv }; + delete process.env.SESSION_COOKIE_SECURE; }); afterAll(() => { @@ -29,6 +30,34 @@ describe('shouldUseSecureCookie', () => { expect(shouldUseSecureCookie()).toBe(false); }); + it('should return true when SESSION_COOKIE_SECURE=true', () => { + process.env.NODE_ENV = 'development'; + process.env.DOMAIN_SERVER = 'http://localhost:3080'; + process.env.SESSION_COOKIE_SECURE = 'true'; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should return false when SESSION_COOKIE_SECURE=false', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'http://10.0.0.5:3080'; + process.env.SESSION_COOKIE_SECURE = 'false'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should trim and normalize SESSION_COOKIE_SECURE values', () => { + process.env.NODE_ENV = 'development'; + process.env.DOMAIN_SERVER = 'http://localhost:3080'; + process.env.SESSION_COOKIE_SECURE = ' TRUE '; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should ignore invalid SESSION_COOKIE_SECURE values', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'https://myapp.example.com'; + process.env.SESSION_COOKIE_SECURE = 'yes'; + expect(shouldUseSecureCookie()).toBe(true); + }); + describe('localhost detection in production', () => { beforeEach(() => { process.env.NODE_ENV = 'production'; diff --git a/packages/api/src/oauth/csrf.ts b/packages/api/src/oauth/csrf.ts index 6ed63968d1..d2e0540c5d 100644 --- a/packages/api/src/oauth/csrf.ts +++ b/packages/api/src/oauth/csrf.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import type { Request, Response, NextFunction } from 'express'; +import { isEnabled } from '~/utils/common'; export const OAUTH_CSRF_COOKIE = 'oauth_csrf'; export const OAUTH_CSRF_MAX_AGE = 10 * 60 * 1000; @@ -10,11 +11,17 @@ export const OAUTH_SESSION_COOKIE_PATH = '/api'; /** * Determines if secure cookies should be used. - * Returns `true` in production unless the server is running on localhost (HTTP). - * This allows cookies to work on `http://localhost` during local development + * SESSION_COOKIE_SECURE=true/false explicitly overrides the environment heuristic. + * Returns `true` in production unless DOMAIN_SERVER uses a localhost-style hostname. + * This allows cookies to work on localhost during local development * even when `NODE_ENV=production` (common in Docker Compose setups). */ export function shouldUseSecureCookie(): boolean { + const secureOverride = process.env.SESSION_COOKIE_SECURE?.trim().toLowerCase(); + if (secureOverride === 'true' || secureOverride === 'false') { + return isEnabled(secureOverride); + } + const isProduction = process.env.NODE_ENV === 'production'; const domainServer = process.env.DOMAIN_SERVER || '';