const crypto = require('crypto'); const COOKIE = 'sdp_session'; const STATE_COOKIE = 'sdp_oauth_state'; // renew this long before expiry so a slow SDP query never runs on a dead token const RENEW_EARLY_MS = 5 * 60 * 1000; function parseCookies(header) { const cookies = {}; for (const part of (header || '').split(';')) { const eq = part.indexOf('='); if (eq < 1) continue; try { cookies[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim()); } catch (err) { // a malformed cookie from somewhere else on the domain; ignore it } } return cookies; } // The whole session lives in the browser: the user's Zoho tokens, encrypted and authenticated // with SESSION_SECRET, in an httpOnly cookie page scripts can't read. The server keeps nothing, // so restarts and new image versions don't sign anyone out. function createSessions({ secret, secure, sessionDays, zoho }) { const key = crypto.createHash('sha256').update(secret).digest(); const maxAgeMs = sessionDays * 24 * 60 * 60 * 1000; const cookieBase = { httpOnly: true, secure, sameSite: 'lax', path: '/' }; function seal(payload) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]); return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64url'); } function open(value) { try { const raw = Buffer.from(value, 'base64url'); const decipher = crypto.createDecipheriv('aes-256-gcm', key, raw.subarray(0, 12)); decipher.setAuthTag(raw.subarray(12, 28)); const body = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]); return JSON.parse(body.toString('utf8')); } catch (err) { // tampered, truncated, or sealed with an old SESSION_SECRET return null; } } function write(res, session) { // the cookie dies with the session; renewing the access token doesn't extend it const maxAge = Math.max(0, session.startedAt + maxAgeMs - Date.now()); res.cookie(COOKIE, seal(session), { ...cookieBase, maxAge }); } function clear(res) { res.clearCookie(COOKIE, cookieBase); } function read(req) { const session = open(parseCookies(req.headers.cookie)[COOKIE] || ''); if (!session || !session.refreshToken) return null; if (Date.now() > session.startedAt + maxAgeMs) return null; return session; } function start(res, tokens, user) { const session = { ...tokens, user, startedAt: Date.now() }; write(res, session); return session; } // A page load fires several calls at once, all carrying the same expired cookie. Renew once // per refresh token and hand the result to all of them, rather than minting a token each. const renewals = new Map(); function renew(refreshToken) { const id = crypto.createHash('sha256').update(refreshToken).digest('hex'); const cached = renewals.get(id); if (cached) return cached; const pending = zoho.refreshAccessToken(refreshToken); renewals.set(id, pending); pending.then( // keep the answer until shortly before it too needs renewing, then forget it tokens => setTimeout(() => renewals.delete(id), Math.max(0, tokens.expiresAt - RENEW_EARLY_MS - Date.now())).unref(), () => renewals.delete(id) ); return pending; } // middleware: puts the signed-in session on req.session, renewing the access token if due async function requireSession(req, res, next) { const session = read(req); if (!session) { clear(res); return res.status(401).json({ error: 'Not signed in' }); } if (Date.now() >= session.expiresAt - RENEW_EARLY_MS) { try { Object.assign(session, await renew(session.refreshToken)); write(res, session); } catch (err) { if (err.zoho || err.response?.status === 400) { // the refresh token was revoked or has been dropped by zoho; sign in again clear(res); return res.status(401).json({ error: 'Your Zoho sign-in has expired' }); } return res.status(502).json({ error: 'Could not reach Zoho: ' + err.message }); } } req.session = session; next(); } // one-time value that ties Zoho's callback to the browser that started the sign-in function startState(res) { const state = crypto.randomBytes(16).toString('hex'); res.cookie(STATE_COOKIE, state, { ...cookieBase, maxAge: 10 * 60 * 1000 }); return state; } function checkState(req, res, given) { const expected = parseCookies(req.headers.cookie)[STATE_COOKIE]; res.clearCookie(STATE_COOKIE, cookieBase); if (!expected || typeof given !== 'string') return false; // hash first so the comparison is fixed-length whatever the query string holds const hash = value => crypto.createHash('sha256').update(value).digest(); return crypto.timingSafeEqual(hash(expected), hash(given)); } return { read, start, clear, requireSession, startState, checkState }; } module.exports = { createSessions };