fix(auth): restore COOKIE_SECURE='auto' default for production
The customer-portal squash inadvertently reverted the upstream/beta fix from PR #427: production NODE_ENV was flipping the cookie Secure flag back to hard `true`, which broke admin login on HTTPS-frontend → HTTP-backend reverse-proxy stacks (browser drops the Secure cookie over HTTP, login loops indefinitely). Restored upstream/beta's tokenUtils.js verbatim and re-layered only the customer cookie helpers (CUSTOMER_COOKIE_NAME, setCustomerAuthCookie, clearCustomerAuthCookie, getCustomerTokenFromRequest) on top. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -2,25 +2,34 @@ const ADMIN_COOKIE_NAME = 'admin_token';
|
|||||||
const GALLERY_COOKIE_NAME = 'gallery_token';
|
const GALLERY_COOKIE_NAME = 'gallery_token';
|
||||||
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
|
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
|
||||||
const GUEST_COOKIE_PREFIX = 'guest_token_';
|
const GUEST_COOKIE_PREFIX = 'guest_token_';
|
||||||
// Customer-account session cookie (#354). Distinct name + path from the
|
// Customer-account session cookie (#354). Distinct name from the admin
|
||||||
// admin cookie so a single browser can hold both an admin and a customer
|
// cookie so a single browser can hold both an admin and a customer
|
||||||
// session without one clobbering the other (e.g. for the admin dogfooding
|
// session without one clobbering the other (e.g. for the admin
|
||||||
// the customer dashboard).
|
// dogfooding the customer dashboard).
|
||||||
const CUSTOMER_COOKIE_NAME = 'customer_token';
|
const CUSTOMER_COOKIE_NAME = 'customer_token';
|
||||||
|
|
||||||
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cookie "Secure" flag mode:
|
* Cookie "Secure" flag mode:
|
||||||
* - true → always set Secure (HTTPS-only)
|
* - true → always set Secure (HTTPS-only — cookie won't be sent over HTTP at all)
|
||||||
* - false → never set Secure (allow plain HTTP)
|
* - false → never set Secure (allow plain HTTP — cookie has no in-flight protection)
|
||||||
* - 'auto' → decide per-request based on req.secure (X-Forwarded-Proto
|
* - 'auto' → decide per-request based on req.secure (X-Forwarded-Proto
|
||||||
* via Express `trust proxy`). Useful when the same deployment
|
* via Express `trust proxy`). Emits Secure when actual HTTPS is
|
||||||
* is reachable over both HTTPS (via reverse proxy) and LAN HTTP.
|
* 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
|
* Default:
|
||||||
* from previous behavior. Users who want the auto mode must opt in with
|
* - production → 'auto' (#427: previously hard `true`, which caused silent
|
||||||
* COOKIE_SECURE=auto in their .env.
|
* 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 secureCookieMode = (() => {
|
||||||
const raw = typeof process.env.COOKIE_SECURE === 'string'
|
const raw = typeof process.env.COOKIE_SECURE === 'string'
|
||||||
@@ -29,8 +38,10 @@ const secureCookieMode = (() => {
|
|||||||
if (raw === 'auto') return 'auto';
|
if (raw === 'auto') return 'auto';
|
||||||
if (raw === 'true') return true;
|
if (raw === 'true') return true;
|
||||||
if (raw === 'false') return false;
|
if (raw === 'false') return false;
|
||||||
// No env var set → legacy default
|
// No env var set → infer from NODE_ENV. Production defaults to 'auto'
|
||||||
return process.env.NODE_ENV === 'production';
|
// (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 sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||||
@@ -98,49 +109,6 @@ function sanitizeSlugForCookie(slug = '') {
|
|||||||
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
|
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort decode of a JWT payload WITHOUT verifying the signature.
|
|
||||||
* Used by the token-extraction helpers below to peek at the `type` claim
|
|
||||||
* so we can decide whether a given Authorization Bearer header is the
|
|
||||||
* RIGHT type of token for the caller. Signature verification still
|
|
||||||
* happens at the route layer via jwt.verify; this peek only filters
|
|
||||||
* out wrong-type tokens.
|
|
||||||
*
|
|
||||||
* Returns null on any parse error so the helpers fall through to cookies
|
|
||||||
* rather than mis-routing to the wrong token type.
|
|
||||||
*/
|
|
||||||
function peekTokenType(token) {
|
|
||||||
if (typeof token !== 'string') return null;
|
|
||||||
const parts = token.split('.');
|
|
||||||
if (parts.length !== 3) return null;
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8'));
|
|
||||||
return typeof payload.type === 'string' ? payload.type : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull a Bearer token from the Authorization header IFF it claims the
|
|
||||||
* expected `type`. Otherwise null.
|
|
||||||
*
|
|
||||||
* Why: when an admin and a customer are logged in the same browser, the
|
|
||||||
* admin's `admin_token` is read from sessionStorage by the events service
|
|
||||||
* and attached as `Authorization: Bearer <admin token>` on requests
|
|
||||||
* unrelated to the admin surface. Without a type-check here, the
|
|
||||||
* gallery-side `/auth/session?slug=…` would happily return that admin
|
|
||||||
* token, decode it as `type:'admin'`, and report the wrong identity —
|
|
||||||
* which is exactly what defeated the prefer-gallery precedence fix on
|
|
||||||
* the dual-cookie test.
|
|
||||||
*/
|
|
||||||
function getBearerTokenIfType(req, expectedType) {
|
|
||||||
const header = req.headers?.authorization;
|
|
||||||
if (!header || !header.startsWith('Bearer ')) return null;
|
|
||||||
const token = header.substring(7);
|
|
||||||
return peekTokenType(token) === expectedType ? token : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAdminAuthCookie(res, token) {
|
function setAdminAuthCookie(res, token) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
|
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
|
||||||
@@ -188,44 +156,28 @@ function clearGalleryAuthCookies(res, slug) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAdminTokenFromRequest(req) {
|
function getAdminTokenFromRequest(req) {
|
||||||
// Honour Authorization: Bearer only if the JWT claims type:'admin'.
|
const header = req.headers?.authorization;
|
||||||
// Stops gallery/customer tokens that happen to be on the request from
|
if (header && header.startsWith('Bearer ')) {
|
||||||
// being mistaken for admin auth (mirrors getGalleryTokenFromRequest's
|
return header.substring(7);
|
||||||
// protection in the other direction).
|
}
|
||||||
const bearer = getBearerTokenIfType(req, 'admin');
|
|
||||||
if (bearer) return bearer;
|
|
||||||
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
|
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer-side equivalent. Cookie-only — we deliberately do NOT honour
|
* Customer JWT (#354). Cookie-only — deliberately no Authorization
|
||||||
* the Authorization: Bearer header on /api/customer/* endpoints.
|
* header fallback so an admin Bearer token attached by the shared
|
||||||
*
|
* events.service.ts auto-auth path can't accidentally satisfy a
|
||||||
* Reason: admins occasionally hit the customer routes from the same
|
* customer-only endpoint and trigger "wrong token type" downstream.
|
||||||
* browser (e.g. while dogfooding the dashboard). The shared axios
|
|
||||||
* client picks up the admin's token from `admin_token` and attaches it
|
|
||||||
* as `Authorization: Bearer <admin token>` for every request. If we
|
|
||||||
* accepted that header here, a logged-in admin's `'admin'` token would
|
|
||||||
* be returned and immediately rejected by the type check downstream as
|
|
||||||
* "wrong token type" — kicking the customer out on every reload.
|
|
||||||
*
|
|
||||||
* Customers don't have an API-token flow, so dropping the header
|
|
||||||
* fallback costs nothing and prevents the cross-contamination.
|
|
||||||
*/
|
*/
|
||||||
function getCustomerTokenFromRequest(req) {
|
function getCustomerTokenFromRequest(req) {
|
||||||
return req.cookies?.[CUSTOMER_COOKIE_NAME] || null;
|
return req.cookies?.[CUSTOMER_COOKIE_NAME] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGalleryTokenFromRequest(req, slug) {
|
function getGalleryTokenFromRequest(req, slug) {
|
||||||
// Honour Authorization: Bearer only if the JWT claims type:'gallery'.
|
const header = req.headers?.authorization;
|
||||||
// The previous unconditional Bearer pickup defeated the prefer-gallery
|
if (header && header.startsWith('Bearer ')) {
|
||||||
// precedence fix on /auth/session?slug=…: an admin token attached as
|
return header.substring(7);
|
||||||
// Bearer (e.g. by the shared events.service.ts auto-auth path) was
|
}
|
||||||
// returned here, decoded as type:'admin' downstream, and mis-rendered
|
|
||||||
// the gallery as "logged in as admin" which kicked the customer back
|
|
||||||
// to the per-event password prompt.
|
|
||||||
const bearer = getBearerTokenIfType(req, 'gallery');
|
|
||||||
if (bearer) return bearer;
|
|
||||||
|
|
||||||
if (!req.cookies) {
|
if (!req.cookies) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user