fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)

Two intertwined bugs reported in #427 by @iSchumi6210:

1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true
   when NODE_ENV=production. Over plain HTTP the browser drops the Secure
   cookie → next /auth/session request returns 401 → redirect back to
   /admin/login → no error shown. picpeak-setup.sh writes
   NODE_ENV=production but never writes COOKIE_SECURE, so every first-time
   install without a reverse proxy hits this.

2. Admin password is generated but admins can't find it. The 001_init.js
   migration writes the generated password to data/ADMIN_CREDENTIALS.txt
   inside the backend container, but picpeak-setup.sh only copies it out
   when --reset-admin-password is passed. Default-path users never see it
   and resort to manual bcrypt updates in psql.

Changes:

- tokenUtils.js: production default goes from `true` to `'auto'`. On real
  HTTPS req.secure is true → Secure flag is still emitted (no security
  regression for reverse-proxy deployments). On plain HTTP req.secure is
  false → Secure flag omitted → login works. Users who explicitly want
  the strict HTTPS-only behaviour can still set COOKIE_SECURE=true.

- .env.example: rewrite the COOKIE_SECURE block to make the new default
  obvious and explain when to override (set =true for strict, =false to
  skip the per-request check, leave unset for the auto behaviour).

- picpeak-setup.sh (both Docker and native paths):
  - Write COOKIE_SECURE=auto explicitly to the generated .env (defense in
    depth so the right behaviour is preserved even if the backend default
    flips again later)
  - After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the
    backend container/data dir to the host data dir, chmod 600, and print
    the email + password to the install output. The credentials file
    remains as a backup record that the operator should delete after
    noting the password.

Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE:

  production, unset      → HTTPS: secure=true ✓  HTTP: secure=false ✓ (was both true)
  production, =true      → both: secure=true (strict opt-in preserved)
  production, =auto      → HTTPS: secure=true   HTTP: secure=false (already-correct)
  development, unset     → both: secure=false (dev unchanged)
This commit is contained in:
Paul Nothaft
2026-05-09 15:55:09 +02:00
parent f6cf470291
commit 5c7de96b7f
3 changed files with 97 additions and 27 deletions
+20 -9
View File
@@ -7,15 +7,24 @@ const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Cookie "Secure" flag mode:
* - true → always set Secure (HTTPS-only)
* - false → never set Secure (allow plain HTTP)
* - true → always set Secure (HTTPS-only — cookie won't be sent over HTTP at all)
* - false → never set Secure (allow plain HTTP — cookie has no in-flight protection)
* - 'auto' → decide per-request based on req.secure (X-Forwarded-Proto
* via Express `trust proxy`). Useful when the same deployment
* is reachable over both HTTPS (via reverse proxy) and LAN HTTP.
* via Express `trust proxy`). Emits Secure when actual HTTPS is
* detected, omits it on plain HTTP. This is the right default
* for deployments reachable via both HTTPS (reverse proxy) and
* LAN HTTP, and for first-time installs that haven't set up a
* reverse proxy yet.
*
* Default: follows NODE_ENV (production → true, dev → false) — unchanged
* from previous behavior. Users who want the auto mode must opt in with
* COOKIE_SECURE=auto in their .env.
* Default:
* - production → 'auto' (#427: previously hard `true`, which caused silent
* login loops over HTTP because the browser drops the
* Secure cookie. 'auto' is strictly more lenient than `true`
* on real HTTPS — req.secure is true → Secure flag still
* emitted — so this is not a security regression for
* reverse-proxy deployments. Users who explicitly want the
* HTTPS-only behaviour can still set COOKIE_SECURE=true.)
* - dev → false (allow http://localhost in browsers without HSTS gymnastics)
*/
const secureCookieMode = (() => {
const raw = typeof process.env.COOKIE_SECURE === 'string'
@@ -24,8 +33,10 @@ const secureCookieMode = (() => {
if (raw === 'auto') return 'auto';
if (raw === 'true') return true;
if (raw === 'false') return false;
// No env var set → legacy default
return process.env.NODE_ENV === 'production';
// No env var set → infer from NODE_ENV. Production defaults to 'auto'
// (per-request) rather than hard `true` so first-time HTTP installs don't
// silently fail (#427).
return process.env.NODE_ENV === 'production' ? 'auto' : false;
})();
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = process.env.COOKIE_DOMAIN;