diff --git a/backend/src/utils/tokenUtils.js b/backend/src/utils/tokenUtils.js index cc0f8286..2b12acb2 100644 --- a/backend/src/utils/tokenUtils.js +++ b/backend/src/utils/tokenUtils.js @@ -2,25 +2,34 @@ const ADMIN_COOKIE_NAME = 'admin_token'; const GALLERY_COOKIE_NAME = 'gallery_token'; const GALLERY_COOKIE_PREFIX = 'gallery_token_'; const GUEST_COOKIE_PREFIX = 'guest_token_'; -// Customer-account session cookie (#354). Distinct name + path from the -// admin 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 -// the customer dashboard). +// Customer-account session cookie (#354). Distinct name from the admin +// 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 the customer dashboard). const CUSTOMER_COOKIE_NAME = 'customer_token'; 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' @@ -29,8 +38,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; @@ -98,49 +109,6 @@ function sanitizeSlugForCookie(slug = '') { 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 ` 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) { if (!token) return; res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res)); @@ -188,44 +156,28 @@ function clearGalleryAuthCookies(res, slug) { } function getAdminTokenFromRequest(req) { - // Honour Authorization: Bearer only if the JWT claims type:'admin'. - // Stops gallery/customer tokens that happen to be on the request from - // being mistaken for admin auth (mirrors getGalleryTokenFromRequest's - // protection in the other direction). - const bearer = getBearerTokenIfType(req, 'admin'); - if (bearer) return bearer; + const header = req.headers?.authorization; + if (header && header.startsWith('Bearer ')) { + return header.substring(7); + } return req.cookies?.[ADMIN_COOKIE_NAME] || null; } /** - * Customer-side equivalent. Cookie-only — we deliberately do NOT honour - * the Authorization: Bearer header on /api/customer/* endpoints. - * - * Reason: admins occasionally hit the customer routes from the same - * 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 ` 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. + * Customer JWT (#354). Cookie-only — deliberately no Authorization + * header fallback so an admin Bearer token attached by the shared + * events.service.ts auto-auth path can't accidentally satisfy a + * customer-only endpoint and trigger "wrong token type" downstream. */ function getCustomerTokenFromRequest(req) { return req.cookies?.[CUSTOMER_COOKIE_NAME] || null; } function getGalleryTokenFromRequest(req, slug) { - // Honour Authorization: Bearer only if the JWT claims type:'gallery'. - // The previous unconditional Bearer pickup defeated the prefer-gallery - // precedence fix on /auth/session?slug=…: an admin token attached as - // 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; + const header = req.headers?.authorization; + if (header && header.startsWith('Bearer ')) { + return header.substring(7); + } if (!req.cookies) { return null;