sdp-request-watcher/src/session.js
Skylar Grant b6ef6a195c Replace shared device-code token with per-user Zoho sign-in
Each user now signs in with their own Zoho account through the web
OAuth flow (Server-based Application client), and every SDP query runs
with that user's token. Nobody who reaches the page can see data
without signing in.

- auth.js: authorization URL, code exchange, token renewal, revoke and
  user info, replacing the device-code flow.
- session.js: tokens kept in an AES-256-GCM encrypted, HttpOnly,
  SameSite=Lax cookie keyed by SESSION_SECRET. The access token renews
  when due, concurrent calls share one renewal, and sessions end after
  SESSION_DAYS.
- routes.js: /auth/login, /auth/callback (state checked), /auth/logout
  (revokes at Zoho); /api/session, /api/closed, /api/workload and
  /api/weather all need a sign-in.
- The server is stateless. boardService.js, tokenStore.js, ADMIN_KEY
  and data/token.json are gone. The page runs the checks itself and
  keeps the closed feed, backfilling 12 hours on first load.
- tickets.js: the closed feed query pages, and SDP 401s are rethrown
  so the user is signed out.
- server.js: PUBLIC_URL, trust proxy, security headers, /healthz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:38:08 -04:00

147 lines
5.5 KiB
JavaScript

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
};