From cfcbec0c85d857cf62c0d873dd432232f00197c9 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 27 May 2026 02:47:09 +0000 Subject: [PATCH] fix(auth): make AUTH_ENABLED=true workable end-to-end Three concrete issues kept the login flow broken on dragonflight.live: 1. mam-api trusted no proxy headers, so behind nginx/Cloudflare the session cookie's `secure` flag and the rate-limiter's IP keying both saw the wrong values. Now sets `app.set('trust proxy', 1)`. 2. Session config was tied to NODE_ENV and lacked sameSite/name. Now: - SESSION_COOKIE_SECURE env (default: true when AUTH_ENABLED) so a site behind HTTPS gets Secure cookies regardless of NODE_ENV. - `sameSite: 'lax'` for predictable post-login redirects. - Renamed to `df.sid` so it's obvious in DevTools. - `rolling: true` extends the 7-day TTL on active use. - SESSION_SECRET is now required when AUTH_ENABLED=true; the server refuses to start with a dev default in prod. 3. login.html silently showed the sign-in panel even when no users exist or auth is off: - New GET /auth/setup-status reports {needs_setup, user_count, auth_enabled}. - login.html calls it on load and auto-flips into setup mode when needs_setup is true, or shows an explicit "auth is off" flash when auth_enabled is false (the previous symptom: logout button did nothing because /auth/me returned a synthetic admin no matter what). - Added a `.flash.info` style for the new neutral notice. 4. Sidebar logout used to call /auth/logout then `window.location .reload()`. With auth off that reload landed back on the synthetic- admin app and looked like nothing happened. It now redirects to /login.html in all states so the operator sees feedback (and the server-side messaging about auth being off) instead of a no-op. Deploy notes for zampp1: - Set AUTH_ENABLED=true and a random SESSION_SECRET in the mam-api environment (e.g. /opt/wild-dragon/.env). - Restart mam-api. - First load of /login.html will auto-route to the setup form so you can create the first admin. --- services/mam-api/src/index.js | 36 ++++++++++++++++++++++++++--- services/mam-api/src/routes/auth.js | 17 ++++++++++++++ services/web-ui/public/login.html | 30 ++++++++++++++++++++++-- services/web-ui/public/shell.jsx | 13 +++++++---- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index cfdeaa6..1188b0d 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -39,25 +39,55 @@ const app = express(); const PORT = process.env.PORT || 3000; // ── Middleware ──────────────────────────────────────────────────────────────── +// Trust the first proxy (nginx in front of us) so req.ip, req.secure, and +// req.protocol reflect the real client request — required for both the +// login rate-limiter's IP keying and `cookie.secure` cookie issuance. +app.set('trust proxy', 1); + app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '50mb' })); const PgSession = ConnectPgSimple(session); +// Session security knobs. +// +// - `secure` is set from SESSION_COOKIE_SECURE (default: true when AUTH_ENABLED). +// `trust proxy` above tells express-session that x-forwarded-proto can be +// trusted, so it issues Secure cookies on HTTPS requests forwarded by +// nginx/Cloudflare even though the proxy → mam-api hop is plain HTTP. +// Set SESSION_COOKIE_SECURE=false explicitly for local-only HTTP testing. +// - `sameSite: 'lax'` ships the cookie on top-level navigations (including +// the post-login redirect from /login.html) but blocks cross-site POSTs. +// - Renamed from default `connect.sid` to `df.sid` so it's obvious in DevTools. +// - `rolling: true` refreshes maxAge on every request so an active user +// doesn't get bounced to login after the 7-day TTL. +const authEnabled = process.env.AUTH_ENABLED === 'true'; +const SESSION_SECRET = process.env.SESSION_SECRET + || (authEnabled + ? (() => { throw new Error('SESSION_SECRET is required when AUTH_ENABLED=true'); })() + : 'dev-only-not-for-production'); + +const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE + ? process.env.SESSION_COOKIE_SECURE === 'true' + : authEnabled; // default: secure cookies whenever auth is on + app.use( session({ + name: 'df.sid', store: new PgSession({ pool, tableName: 'sessions', pruneSessionInterval: 3600, }), - secret: process.env.SESSION_SECRET || 'change-me-in-production', + secret: SESSION_SECRET, resave: false, saveUninitialized: false, + rolling: true, cookie: { - secure: process.env.NODE_ENV === 'production', + secure: SESSION_COOKIE_SECURE, httpOnly: true, - maxAge: 1000 * 60 * 60 * 24, + sameSite: 'lax', + maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days }, }) ); diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 07578b9..6aabaf4 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -201,6 +201,23 @@ router.get('/me', async (req, res) => { } }); +// --------------------------------------------------------------------------- +// GET /setup-status — does ANY user exist? login.html flips into setup mode +// automatically when this returns { needs_setup: true }, instead of forcing +// the operator to click "Create admin account". +// --------------------------------------------------------------------------- +router.get('/setup-status', async (req, res, next) => { + try { + const count = await pool.query('SELECT COUNT(*) FROM users'); + const n = parseInt(count.rows[0].count, 10); + res.json({ + needs_setup: n === 0, + user_count: n, + auth_enabled: process.env.AUTH_ENABLED === 'true', + }); + } catch (err) { next(err); } +}); + // --------------------------------------------------------------------------- // POST /setup — one-time first-admin bootstrap // --------------------------------------------------------------------------- diff --git a/services/web-ui/public/login.html b/services/web-ui/public/login.html index 42aaa9a..7971670 100644 --- a/services/web-ui/public/login.html +++ b/services/web-ui/public/login.html @@ -162,6 +162,8 @@ .flash.error::before { background: var(--signal-bad); } .flash.success { display: block; border-color: var(--signal-good); } .flash.success::before { background: var(--signal-good); } + .flash.info { display: block; border-color: var(--accent, #5b7cfa); } + .flash.info::before { background: var(--accent, #5b7cfa); } .setup-link { text-align: center; @@ -256,9 +258,33 @@ const flash = $('flash'); function showFlash(m,t){ flash.textContent=m; flash.className='flash '+t; } function clearFlash(){ flash.className='flash'; flash.textContent=''; } + function showSetup(){ $('login-panel').style.display='none'; $('setup-panel').style.display='block'; } + function showLogin(){ $('setup-panel').style.display='none'; $('login-panel').style.display='block'; } - $('show-setup').onclick = e => { e.preventDefault(); clearFlash(); $('login-panel').style.display='none'; $('setup-panel').style.display='block'; }; - $('show-login').onclick = e => { e.preventDefault(); clearFlash(); $('setup-panel').style.display='none'; $('login-panel').style.display='block'; }; + $('show-setup').onclick = e => { e.preventDefault(); clearFlash(); showSetup(); }; + $('show-login').onclick = e => { e.preventDefault(); clearFlash(); showLogin(); }; + + // First-run detection: if no users exist, skip the sign-in panel entirely + // and present the create-admin form. This is the only state in which the + // app is unusable without intervention, so we want the operator routed + // there automatically rather than relying on them to click the small link. + (async () => { + try { + const r = await fetch(API + '/setup-status', { credentials: 'same-origin' }); + if (r.ok) { + const d = await r.json(); + if (d.needs_setup) { + showSetup(); + showFlash('No accounts yet — create the first admin to continue.', 'info'); + } else if (!d.auth_enabled) { + // Auth is off server-side; logging in does nothing. Tell the + // operator clearly instead of letting them fill out the form + // and watch the redirect loop back to /login.html. + showFlash('Authentication is disabled on the server (AUTH_ENABLED=false). Set AUTH_ENABLED=true in mam-api and restart.', 'error'); + } + } + } catch (_) { /* offline → leave the login panel visible */ } + })(); $('login-form').onsubmit = async (e) => { e.preventDefault(); clearFlash(); diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index 7f9ab28..87a57f8 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -178,11 +178,14 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {