From 7dea2734eade258ae0aa67186cc230d2b3bef30b Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Thu, 15 Oct 2026 12:40:15 -0400 Subject: [PATCH] Sessions: 180-day lifetime with sliding renewal Co-Authored-By: Claude Sonnet 5.5 --- app/server.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/server.js b/app/server.js index fdb57b9..8f3e0e9 100644 --- a/app/server.js +++ b/app/server.js @@ -35,8 +35,14 @@ function parseCookies(req) { }).filter(([k]) => k)); } +// Sessions last SESSION_DAYS and slide: any signed-in request more than a day after the +// cookie was issued gets a fresh one, so people who keep using the app never sign in again. +const SESSION_DAYS = 180; +const SESSION_MS = SESSION_DAYS * 24 * 60 * 60 * 1000; +const SESSION_RENEW_AFTER_MS = 24 * 60 * 60 * 1000; + function signSession(username, epoch = 0) { - const expiry = Date.now() + 30 * 24 * 60 * 60 * 1000; + const expiry = Date.now() + SESSION_MS; const payload = Buffer.from(`${username}|${expiry}|${epoch}`).toString('base64url'); const sig = crypto.createHmac('sha256', SESSION_SECRET).update(payload).digest('base64url'); return `${payload}.${sig}`; @@ -56,12 +62,12 @@ function verifySession(token) { const decoded = Buffer.from(payload, 'base64url').toString(); const [username, expiry, epoch] = decoded.split('|'); if (!username || !expiry || Date.now() > parseInt(expiry)) return null; - return { username, epoch: parseInt(epoch) || 0 }; + return { username, epoch: parseInt(epoch) || 0, expiry: parseInt(expiry) }; } function sessionCookie(req, token) { const secure = req.secure || req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''; - return `dsession=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 3600}; SameSite=Lax${secure}`; + return `dsession=${token}; HttpOnly; Path=/; Max-Age=${SESSION_DAYS * 24 * 3600}; SameSite=Lax${secure}`; } function hashPassword(password, salt) { @@ -142,12 +148,20 @@ function currentUser(req) { if (!s) return null; const u = findUser(s.username); if (!u || u.disabled || (u.epoch || 0) !== s.epoch) return null; + req.sessionExpiry = s.expiry; return u; } function requireDashboardAuth(req, res, next) { const user = currentUser(req); - if (user) { req.dashUser = user; return next(); } + if (user) { + req.dashUser = user; + // Sliding renewal (routes that set their own cookie later simply overwrite this header) + if (req.sessionExpiry - Date.now() < SESSION_MS - SESSION_RENEW_AFTER_MS) { + res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch || 0))); + } + return next(); + } if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorized' }); res.redirect('/dashboard/login'); }