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>
147 lines
5.5 KiB
JavaScript
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
|
|
};
|