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>
This commit is contained in:
Skylar Grant 2026-10-15 19:38:08 -04:00
parent 3e79a24efd
commit b6ef6a195c
11 changed files with 567 additions and 585 deletions

1
.gitignore vendored
View File

@ -10,7 +10,6 @@ lerna-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
data/
pids
*.pid
*.seed

View File

@ -1,7 +1,7 @@
{
"name": "sdp-request-watcher",
"version": "1.0.0",
"description": "A local NodeJS app and web server to watch Zoho ManageEngine SDP via API.",
"description": "A hosted help desk dashboard for ManageEngine ServiceDesk Plus Cloud, with per-user Zoho sign-in.",
"repository": {
"type": "git",
"url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git"

View File

@ -1,115 +1,90 @@
const axios = require('axios');
async function getDeviceCode(clientId) {
const params = new URLSearchParams({
client_id: clientId,
scope: 'SDPOnDemand.requests.READ',
grant_type: 'device_request',
access_type: 'offline'
});
// MCCS is on Zoho's US data centre; users on another DC would need accounts.zoho.<tld>
const ACCOUNTS_URL = 'https://accounts.zoho.com';
// profile.READ is only for showing who's signed in; the dashboard itself needs requests.READ
const SCOPES = 'SDPOnDemand.requests.READ,AaaServer.profile.READ';
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params);
return res.data;
// zoho reports most failures as a 200 with an error field, not an http error
function zohoError(data, fallback) {
const err = new Error('Zoho: ' + (data?.error || fallback));
err.zoho = true;
return err;
}
function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) {
const pollParams = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code: deviceCode,
grant_type: 'device_token'
});
return new Promise((resolve, reject) => {
const deadline = Date.now() + (expiresIn || 300) * 1000;
const timer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(timer);
reject(new Error('Expired try again.'));
return;
}
try {
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
if (res.data.access_token) {
clearInterval(timer);
resolve(res.data);
}
} catch (err) {
}
}, interval + 500);
});
}
async function startAuthentication({ clientId, clientSecret, state }) {
state.auth = 'pending';
state.lastError = null;
const deviceData = await getDeviceCode(clientId);
state.verificationUrl = deviceData.verification_uri_complete;
state.userCode = deviceData.user_code;
console.log('Open this URL and authorize:', deviceData.verification_uri_complete);
console.log('Code: ', deviceData.user_code);
try {
const tokenData = await pollForToken(
clientId,
clientSecret,
deviceData.device_code,
deviceData.interval,
deviceData.expires_in
);
state.auth = 'connected';
state.verificationUrl = null;
state.userCode = null;
// only issued because the device code asked for access_type=offline
if (!tokenData.refresh_token) {
console.warn('Zoho sent no refresh token; the board will stop when the access token expires');
}
return {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || null,
expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000
};
} catch (err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
return null;
function createZohoAuth({ clientId, clientSecret, redirectUri }) {
// the page Zoho shows to approve the dashboard; state ties the callback to this browser
function authorizeUrl(state) {
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
scope: SCOPES,
redirect_uri: redirectUri,
state,
// offline is what gets us a refresh token; consent makes zoho issue one every time
access_type: 'offline',
prompt: 'consent'
});
return `${ACCOUNTS_URL}/oauth/v2/auth?${params}`;
}
}
// Swaps the long-lived refresh token for a new access token (they last about an hour).
async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
});
async function tokenRequest(fields) {
const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...fields });
const res = await axios.post(`${ACCOUNTS_URL}/oauth/v2/token`, params);
if (!res.data?.access_token) throw zohoError(res.data, 'no access token');
return res.data;
}
const res = await axios.post('https://accounts.zoho.com/oauth/v2/token', params);
async function exchangeCode(code) {
const data = await tokenRequest({ grant_type: 'authorization_code', redirect_uri: redirectUri, code });
if (!data.refresh_token) throw zohoError(data, 'no refresh token');
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000
};
}
// zoho reports a bad refresh token as a 200 with an error field, not an http error
if (!res.data || !res.data.access_token) {
const err = new Error('Zoho refused the saved sign-in (' + (res.data?.error || 'no access token') + ')');
err.revoked = true;
throw err;
// access tokens last about an hour; the refresh token lasts until revoked
async function refreshAccessToken(refreshToken) {
const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken });
return {
accessToken: data.access_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000
};
}
// zoho keeps at most 20 refresh tokens per user per client and silently drops the oldest,
// so revoke on sign-out rather than leaving them to pile up
async function revokeToken(refreshToken) {
await axios.post(`${ACCOUNTS_URL}/oauth/v2/token/revoke`, new URLSearchParams({ token: refreshToken }));
}
async function getUser(accessToken) {
try {
const res = await axios.get(`${ACCOUNTS_URL}/oauth/user/info`, {
headers: { Authorization: `Zoho-oauthtoken ${accessToken}` }
});
return {
name: res.data.Display_Name || [res.data.First_Name, res.data.Last_Name].filter(Boolean).join(' ') || null,
email: res.data.Email || null
};
} catch (err) {
// only cosmetic; the page just says "Signed in" without a name
console.warn('Could not read Zoho profile:', err.response?.data || err.message);
return { name: null, email: null };
}
}
return {
accessToken: res.data.access_token,
expiresAt: Date.now() + (res.data.expires_in || 3600) * 1000
authorizeUrl,
exchangeCode,
refreshAccessToken,
revokeToken,
getUser
};
}
module.exports = {
startAuthentication,
refreshAccessToken
createZohoAuth
};

View File

@ -1,230 +0,0 @@
const { startAuthentication, refreshAccessToken } = require('./auth');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
// renew this long before expiry so a slow check never runs on a dead token
const RENEW_EARLY_MS = 5 * 60 * 1000;
function createBoardService({ clientId, clientSecret, tokenStore, technicians, statusSet, closedStatuses, unassignedExcludedStatuses, scope, windowMinutes, checkMinutes, workloadMinutes }) {
const state = {
auth: 'disconnected',
verificationUrl: null,
userCode: null,
lastCheck: null,
lastError: null
};
const workload = {
updatedAt: null,
error: null,
truncated: false,
statuses: [],
technicians: []
};
const unassigned = {
updatedAt: null,
error: null,
truncated: false,
count: 0,
discarded: 0,
tickets: []
};
const tickets = new Map();
// { accessToken, refreshToken, expiresAt }
let token = null;
let renewing = null;
let pollingTimer = null;
let workloadTimer = null;
function saveToken() {
try {
tokenStore.save(token);
} catch (err) {
console.error('Could not save token, it will be lost on restart:', err.message);
}
}
// the saved sign-in is no good any more; go back to needing an admin to connect
function dropToken(message) {
token = null;
tokenStore.clear();
stopPolling();
state.auth = 'error';
state.lastError = message;
}
async function renewToken() {
try {
const renewed = await refreshAccessToken({ clientId, clientSecret, refreshToken: token.refreshToken });
token = { ...token, ...renewed };
saveToken();
console.log('Access token renewed');
} catch (err) {
// a 400 from zoho means the refresh token itself was rejected; anything else is
// probably the network, so keep the token and try again on the next check
if (err.revoked || err.response?.status === 400) {
dropToken(err.message + '. An admin needs to connect again.');
} else {
throw err;
}
}
}
async function getAccessToken() {
if (!token) return null;
if (Date.now() < token.expiresAt - RENEW_EARLY_MS) return token.accessToken;
if (!token.refreshToken) {
dropToken('The Zoho sign-in expired and there is no refresh token. An admin needs to connect again.');
return null;
}
// the closed and workload checks can both land here at once; renew only once
if (!renewing) {
renewing = renewToken().finally(() => {
renewing = null;
});
}
await renewing;
return token ? token.accessToken : null;
}
async function refreshTickets() {
const accessToken = await getAccessToken();
if (!accessToken) return;
await fetchClosedTickets({
accessToken,
state,
tickets,
technicians,
statusSet,
scope,
windowMinutes
});
}
async function refreshWorkload() {
const accessToken = await getAccessToken();
if (!accessToken) return;
await fetchWorkload({
accessToken,
workload,
technicians,
closedStatuses,
scope
});
await fetchUnassigned({
accessToken,
unassigned,
excludedStatuses: unassignedExcludedStatuses,
scope
});
}
function startPolling() {
if (pollingTimer) return;
pollingTimer = setInterval(() => {
refreshTickets().catch(err => {
state.lastError = err.message;
state.lastCheck = Date.now();
});
}, checkMinutes * 60 * 1000);
workloadTimer = setInterval(() => {
refreshWorkload().catch(err => {
workload.error = err.message;
workload.updatedAt = Date.now();
});
}, workloadMinutes * 60 * 1000);
}
function stopPolling() {
clearInterval(pollingTimer);
clearInterval(workloadTimer);
pollingTimer = null;
workloadTimer = null;
}
async function startChecks() {
// timers first, so a failed first check (say the network is down at boot) still retries
startPolling();
await refreshTickets().catch(err => {
state.lastError = err.message;
state.lastCheck = Date.now();
});
await refreshWorkload().catch(err => {
workload.error = err.message;
workload.updatedAt = Date.now();
});
}
async function connect() {
const authResult = await startAuthentication({
clientId,
clientSecret,
state
});
if (!authResult?.accessToken) return;
token = authResult;
saveToken();
console.log('Authorization successful');
await startChecks();
}
// picks up the sign-in saved by a previous run, if there is one
async function resume() {
const saved = tokenStore.load();
if (!saved) return;
token = saved;
state.auth = 'connected';
console.log('Resumed saved Zoho sign-in');
await startChecks();
}
function failConnection(err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
}
function getState() {
return {
...state,
windowMinutes,
checkMinutes,
workloadMinutes,
watching: technicians,
scope,
serverTime: Date.now(),
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt),
workload,
unassigned
};
}
async function checkNow() {
await Promise.all([refreshTickets(), refreshWorkload()]);
}
return {
connect,
resume,
checkNow,
failConnection,
getState
};
}
module.exports = {
createBoardService
};

View File

@ -1,5 +1,12 @@
# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback
CLIENT_ID=
CLIENT_SECRET=
# the address people open the dashboard at (no trailing slash); defaults to http://localhost:3000
PUBLIC_URL=https://dashboard.example.com
# encrypts the sign-in cookie; at least 32 characters, e.g. from `openssl rand -hex 32`. Changing it signs everyone out
SESSION_SECRET=
# optional: days before a sign-in expires and Zoho is asked again (default 7)
SESSION_DAYS=7
TECHNICIAN_NAMES=First Last, First Last
STATUS_NAMES=Closed,Resolved
# only requests from these sites are shown (central region: KVCC, EMCC, CMCC, MCCS and their satellite sites)
@ -13,7 +20,3 @@ OWM_API_KEY=
OWM_ZIP_CODE=04937
# optional: how often the technician/status table is recounted, in minutes (default 5)
WORKLOAD_MINUTES=5
# optional: key for Connect and Refresh now (enter it with the page's Admin button). Without it, only this machine can use them
ADMIN_KEY=
# optional: where the Zoho sign-in is saved so it survives restarts (default data/token.json)
TOKEN_FILE=

View File

@ -136,15 +136,10 @@ h1 {
max-width: 80ch;
}
.code {
display: inline-block;
margin-bottom: 16px;
padding: 10px 16px;
background: var(--signal-soft);
.user {
font-family: var(--mono);
font-size: 26px;
font-weight: 600;
letter-spacing: 0.12em;
font-size: 12px;
color: var(--muted);
}
button, .button {
@ -549,15 +544,16 @@ body > footer p + p { margin-top: 4px; }
<header>
<h1>Help desk dashboard</h1>
<div class="status" id="status" data-auth="disconnected">
<span class="dot"></span><span id="statusText">Not connected</span>
<div class="status" id="status" data-auth="loading">
<span class="dot"></span><span id="statusText">Checking sign-in…</span>
</div>
<div class="actions">
<button class="toggle" id="soundToggle" aria-pressed="true" onclick="toggleSound()">
<span class="track"></span>Sound on close
</button>
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
<button class="quiet" id="adminBtn" onclick="toggleAdmin()" hidden>Admin</button>
<span class="user" id="user" hidden></span>
<button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button>
</div>
</header>
@ -591,48 +587,35 @@ function toggleSound() {
}
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
// connect and refresh are admin-only; the key is remembered per browser and sent with every call
let adminKey = '';
try { adminKey = localStorage.getItem('sdp-admin-key') || ''; } catch (err) {}
function api(url, options = {}) {
return fetch(url, { ...options, headers: adminKey ? { 'X-Admin-Key': adminKey } : {} });
}
function setAdminKey(key) {
adminKey = key;
try {
if (key) localStorage.setItem('sdp-admin-key', key);
else localStorage.removeItem('sdp-admin-key');
} catch (err) {}
}
async function toggleAdmin() {
if (adminKey) {
setAdminKey('');
return load();
}
const key = prompt('Admin key');
if (!key) return;
setAdminKey(key.trim());
await load();
if (data && !data.isAdmin) {
setAdminKey('');
alert('That admin key was not accepted.');
load();
}
}
let data = null;
let data = {
auth: 'loading', // loading, connected or disconnected (signed out)
tickets: [],
workload: {},
unassigned: {},
lastCheck: null,
lastError: null
};
let seen = new Set();
let firstLoad = true;
let flashTechs = new Set();
// the browser builds the closed feed now; the server only answers each check with the user's token
const feed = new Map();
let feedReady = false;
let timers = [];
let signInProblem = null;
const STATUS_TEXT = {
disconnected: 'Not connected',
pending: 'Waiting for authorization',
connected: 'Live',
error: 'Stopped'
loading: 'Checking sign-in…',
disconnected: 'Signed out',
connected: 'Live'
};
// what /auth/callback means when it sends someone back with ?signin=...
const SIGNIN_PROBLEMS = {
denied: 'Zoho sign-in was cancelled or refused.',
expired: 'The sign-in took too long or was started in another tab. Try again.',
failed: "Zoho sign-in didn't complete. Try again, and check the server log if it keeps happening."
};
function elapsed(ms) {
@ -651,19 +634,89 @@ function esc(text) {
return String(text ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
async function load() {
try {
const res = await api('/api/state');
data = await res.json();
render();
} catch (err) {
$('statusText').textContent = 'Server unreachable';
// every call carries the session cookie; a 401 means the sign-in is gone or was revoked
async function api(url, options) {
const res = await fetch(url, { credentials: 'same-origin', ...options });
if (res.status === 401) {
signedOut();
throw new Error('Signed out');
}
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Request failed (' + res.status + ')');
return body;
}
function signedOut() {
timers.forEach(clearInterval);
timers = [];
data.auth = 'disconnected';
render();
}
async function start() {
const params = new URLSearchParams(location.search);
if (params.has('signin')) {
signInProblem = SIGNIN_PROBLEMS[params.get('signin')] || SIGNIN_PROBLEMS.failed;
history.replaceState(null, '', location.pathname);
}
let session;
try {
session = await api('/api/session');
} catch (err) {
if (data.auth === 'disconnected') return;
$('statusText').textContent = 'Server unreachable';
setTimeout(start, 15000);
return;
}
Object.assign(data, session, { auth: 'connected' });
render();
checkClosed();
checkWorkload();
loadWeather();
timers.push(
setInterval(checkClosed, data.checkMinutes * 60 * 1000),
setInterval(checkWorkload, data.workloadMinutes * 60 * 1000),
setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner
);
}
async function checkClosed() {
// the whole feed on first load, then a short window that overlaps the last check
const minutes = feedReady ? data.windowMinutes : data.feedHours * 60;
try {
const result = await api('/api/closed?minutes=' + minutes);
for (const t of result.tickets) feed.set(String(t.id), t);
const cutoff = Date.now() - data.feedHours * 60 * 60 * 1000;
for (const [id, t] of feed) {
if (t.closedAt < cutoff) feed.delete(id);
}
data.tickets = [...feed.values()].sort((a, b) => b.closedAt - a.closedAt);
data.lastCheck = result.checkedAt;
data.lastError = result.error;
if (!result.error) feedReady = true;
} catch (err) {
if (data.auth !== 'connected') return;
data.lastError = err.message;
data.lastCheck = Date.now();
}
render();
}
async function checkWorkload() {
try {
const result = await api('/api/workload');
data.workload = result.workload;
data.unassigned = result.unassigned;
} catch (err) {
if (data.auth !== 'connected') return;
data.workload = { ...data.workload, error: err.message, updatedAt: Date.now() };
}
render();
}
function render() {
if (!data) return;
// the watcher: anything we haven't seen before is a fresh close
const fresh = data.tickets.filter(t => !seen.has(t.id));
flashTechs = new Set(firstLoad ? [] : fresh.map(t => t.technician));
@ -671,55 +724,34 @@ function render() {
$('status').dataset.auth = data.auth;
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
$('checkBtn').hidden = data.auth !== 'connected' || !data.isAdmin;
// without a key configured, admin just means "on the server's machine"; nothing to enter
$('adminBtn').hidden = !data.adminKeyRequired;
$('adminBtn').textContent = adminKey ? 'Lock' : 'Admin';
const live = data.auth === 'connected';
$('checkBtn').hidden = !live;
$('signOutBtn').hidden = !live;
$('user').hidden = !live;
const user = data.user || {};
$('user').textContent = user.name || user.email || 'Signed in';
$('user').title = user.email || '';
const wl = data.workload || {};
$('meta').innerHTML = [
const s = data.settings || { sites: [], groups: [], technicians: [] };
$('meta').innerHTML = !live ? '' : [
'Closed tickets checked every ' + data.checkMinutes + ' min',
'Status counts every ' + data.workloadMinutes + ' min',
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
data.scope && data.scope.sites.length ? 'Sites: ' + data.scope.sites.join(', ') : '',
data.scope && data.scope.groups.length ? 'Groups: ' + data.scope.groups.join(', ') : '',
s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians',
s.sites.length ? 'Sites: ' + s.sites.join(', ') : '',
s.groups.length ? 'Groups: ' + s.groups.join(', ') : '',
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
let html = '';
if ((data.auth === 'disconnected' || data.auth === 'error') && !data.isAdmin) {
html += `<div class="panel">
<h2>Not connected to the help desk</h2>
<p>An admin needs to connect this board to Zoho. It starts updating on its own once they do.</p>
</div>`;
} else if (data.auth === 'disconnected' || data.auth === 'error') {
html += `<div class="panel">
<h2>Connect to the help desk</h2>
<p>Zoho needs you to approve this board once. Connecting opens an authorization
page and starts the checks as soon as you approve.</p>
<button onclick="connect(this)">Connect</button>
</div>`;
}
if (data.auth === 'pending' && !data.isAdmin) {
html += `<div class="panel">
<h2>Connecting</h2>
<p>An admin is approving this board in Zoho.</p>
</div>`;
} else if (data.auth === 'pending' && !(data.userCode && data.verificationUrl)) {
// Zoho hasn't handed back the device code yet; nothing to open until it does
html += `<div class="panel">
<h2>Approve the board</h2>
<p>Requesting a device code from Zoho…</p>
</div>`;
} else if (data.auth === 'pending') {
html += `<div class="panel">
<h2>Approve the board</h2>
<p>The authorization page opens in a new tab. Confirm this code matches the one shown there,
approve it, then close that tab. This page picks up the connection on its own.</p>
<div class="code">${esc(data.userCode)}</div>
<a class="button" href="${esc(data.verificationUrl)}" target="_blank" rel="noopener noreferrer">Open authorization page ↗</a>
if (data.auth === 'disconnected') {
html += `${signInProblem ? `<div class="error"><strong>Not signed in</strong>${esc(signInProblem)}</div>` : ''}
<div class="panel">
<h2>Sign in to the help desk dashboard</h2>
<p>Sign in with your own Zoho account, the one you use for ServiceDesk Plus. The dashboard
only reads requests. Your sign-in stays in this browser until you sign out or it expires.</p>
<a class="button" href="/auth/login">Sign in with Zoho</a>
</div>`;
}
@ -751,7 +783,8 @@ function render() {
if (verifyOpen) document.querySelector('.verify').open = true;
for (const t of data.tickets) seen.add(t.id);
firstLoad = false;
// stay quiet until the first full feed has drawn, so a page load doesn't ding for old closes
if (feedReady) firstLoad = false;
}
function renderStats(wl) {
@ -912,7 +945,7 @@ function renderFeed() {
: '<div class="empty"><p>Nothing closed yet.</p></div>';
return `<section class="section">
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.windowMinutes} min, kept 12h</span></div>
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.feedHours}h</span></div>
${list}
</section>`;
}
@ -936,45 +969,37 @@ function renderTicket(ticket) {
</article>`;
}
async function connect(btn) {
btn.disabled = true;
btn.textContent = 'Connecting...';
await api('/api/connect', { method: 'POST' });
// check quickly until the device code arrives instead of waiting on the 5s poll
for (let i = 0; i < 20; i++) {
await load();
if (!data || data.auth !== 'pending' || data.userCode) break;
await new Promise(resolve => setTimeout(resolve, 500));
}
}
async function checkNow(btn) {
btn.disabled = true;
btn.textContent = 'Refreshing...';
try {
await api('/api/check', { method: 'POST' });
await Promise.all([checkClosed(), checkWorkload()]);
} finally {
btn.disabled = false;
btn.textContent = 'Refresh now';
}
load();
}
async function signOut(btn) {
btn.disabled = true;
try {
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
} finally {
location.reload();
}
}
let weather = null;
async function loadWeather() {
try {
const res = await fetch('/api/weather');
weather = await res.json();
weather = await api('/api/weather');
render();
} catch (err) {}
}
load();
loadWeather();
setInterval(load, 5000); // pull fresh state from the server
setInterval(loadWeather, 10 * 60 * 1000); // server caches for 10 min, no point asking sooner
setInterval(render, 20000); // re-age the rails between pulls
start();
setInterval(render, 20000); // re-age the rails between checks
</script>
<footer>
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p>

View File

@ -1,71 +1,141 @@
const crypto = require('crypto');
const express = require('express');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
// Stand-in for real user sign-in (roadmap phase 3). With ADMIN_KEY set, the request must
// carry it in X-Admin-Key. Without it, only requests from this machine count as admin.
// Loopback isn't trusted once a key is set, since behind a reverse proxy everything is loopback.
function createAdminCheck(adminKey) {
const hash = value => crypto.createHash('sha256').update(String(value)).digest();
const keyHash = adminKey ? hash(adminKey) : null;
return function isAdmin(req) {
if (!keyHash) return LOOPBACK.has(req.socket.remoteAddress);
const given = req.get('X-Admin-Key');
return !!given && crypto.timingSafeEqual(hash(given), keyHash);
};
}
function createRoutes(boardService, weatherService, { adminKey } = {}) {
// Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account.
function createAuthRoutes({ zoho, sessions }) {
const router = express.Router();
const isAdmin = createAdminCheck(adminKey);
function requireAdmin(req, res, next) {
if (isAdmin(req)) return next();
res.status(403).json({ ok: false, error: 'Admin only' });
}
router.get('/weather', async (req, res) => {
res.json(await weatherService.getWeather());
router.get('/login', (req, res) => {
res.redirect(zoho.authorizeUrl(sessions.startState(res)));
});
router.get('/state', (req, res) => {
const admin = isAdmin(req);
const state = { ...boardService.getState(), isAdmin: admin, adminKeyRequired: !!adminKey };
// whoever holds the device code decides whose Zoho account the board runs as
if (!admin) {
state.verificationUrl = null;
state.userCode = null;
router.get('/callback', async (req, res) => {
if (!sessions.checkState(req, res, req.query.state)) {
return res.redirect('/?signin=expired');
}
res.json(state);
});
router.post('/connect', requireAdmin, async (req, res) => {
const currentState = boardService.getState();
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
return res.json({ ok: true });
if (req.query.error || !req.query.code) {
console.warn('Sign-in refused by Zoho:', req.query.error || 'no code');
return res.redirect('/?signin=denied');
}
try {
boardService.connect().catch(err => {
boardService.failConnection(err);
});
res.json({ ok: true });
const tokens = await zoho.exchangeCode(String(req.query.code));
const user = await zoho.getUser(tokens.accessToken);
sessions.start(res, tokens, user);
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
res.redirect('/');
} catch (err) {
boardService.failConnection(err);
res.status(500).json({ ok: false, error: err.message });
console.error('Sign-in failed:', err.response?.data || err.message);
res.redirect('/?signin=failed');
}
});
router.post('/check', requireAdmin, async (req, res) => {
await boardService.checkNow();
router.post('/logout', async (req, res) => {
const session = sessions.read(req);
sessions.clear(res);
if (session) {
console.log(`Signed out: ${session.user?.email || session.user?.name || 'unknown user'}`);
await zoho.revokeToken(session.refreshToken).catch(err => {
console.warn('Could not revoke Zoho token:', err.message);
});
}
res.json({ ok: true });
});
return router;
}
// Data: each call runs against SDP with the caller's own token. Nothing is cached per user,
// so the browser decides how often to ask (see checkMinutes and workloadMinutes).
function createApiRoutes({ sessions, weatherService, settings, timing }) {
const router = express.Router();
router.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
router.use(sessions.requireSession);
// turns an SDP 401 thrown by tickets.js into a sign-out
const signedOutOnSdp401 = handler => async (req, res) => {
try {
await handler(req, res);
} catch (err) {
if (err.response?.status !== 401) throw err;
sessions.clear(res);
res.status(401).json({ error: 'ServiceDesk Plus rejected your sign-in' });
}
};
router.get('/session', (req, res) => {
res.json({
user: req.session.user,
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
// the region filters every query uses; a future settings panel would start from these
settings: {
sites: settings.scope.sites,
groups: settings.scope.groups,
technicians: settings.technicians
},
checkMinutes: timing.checkMinutes,
windowMinutes: timing.windowMinutes,
workloadMinutes: timing.workloadMinutes,
feedHours: timing.feedHours
});
});
router.get('/closed', signedOutOnSdp401(async (req, res) => {
// the page asks for the full feed on first load, then a short overlapping window each check
const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), timing.feedHours * 60);
const state = { lastCheck: null, lastError: null };
const tickets = new Map();
await fetchClosedTickets({
accessToken: req.session.accessToken,
state,
tickets,
technicians: settings.technicians,
statusSet: settings.statusSet,
scope: settings.scope,
windowMinutes: minutes
});
res.json({
checkedAt: state.lastCheck,
error: state.lastError,
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
});
}));
router.get('/workload', signedOutOnSdp401(async (req, res) => {
const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] };
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
await fetchWorkload({
accessToken: req.session.accessToken,
workload,
technicians: settings.technicians,
closedStatuses: settings.closedStatuses,
scope: settings.scope
});
await fetchUnassigned({
accessToken: req.session.accessToken,
unassigned,
excludedStatuses: settings.unassignedExcludedStatuses,
scope: settings.scope
});
res.json({ workload, unassigned });
}));
router.get('/weather', async (req, res) => {
res.json(await weatherService.getWeather());
});
return router;
}
module.exports = {
createRoutes
createAuthRoutes,
createApiRoutes
};

View File

@ -5,16 +5,22 @@ require('dotenv').config({ quiet: true });
const path = require('path');
const express = require('express');
const { createBoardService } = require('./boardService');
const { createRoutes } = require('./routes');
const { createZohoAuth } = require('./auth');
const { createSessions } = require('./session');
const { createAuthRoutes, createApiRoutes } = require('./routes');
const { createWeatherService } = require('./weather');
const { createTokenStore } = require('./tokenStore');
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE, ADMIN_KEY } = process.env;
const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
const PORT = process.env.PORT || 3000; // default 3000
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error('Client id and secret missing from .env');
const problems = [
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null
].filter(Boolean);
if (problems.length) {
problems.forEach(p => console.error(p));
process.exit(1);
}
@ -22,54 +28,71 @@ const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filte
const technicians = toList(TECHNICIAN_NAMES);
const statuses = toList(STATUS_NAMES);
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
// statuses that count as done; everything else shows up in the workload table
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
// statuses the unassigned counter ignores, to match the ManageEngine view
const unassignedExcludedStatuses = process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
: closedStatuses;
// restricts every query to these sites/groups (the region); empty means no restriction
const scope = { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) };
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
const WORKLOAD_MINUTES = Number(process.env.WORKLOAD_MINUTES) || 5; // how often the status table is recounted in minutes
// the region, applied to every query for every user. Kept server side for now; per-browser
// settings (see docs/ROADMAP.md) would override these within limits the server checks.
const settings = {
technicians,
statusSet: new Set(statuses.map(s => s.toLowerCase())),
closedStatuses,
// statuses the unassigned counter ignores, to match the ManageEngine view
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
: closedStatuses,
// restricts every query to these sites/groups; empty means no restriction
scope: { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) }
};
// the saved Zoho sign-in; holds a refresh token, so keep it out of git and off shared disks
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(__dirname, '..', 'data', 'token.json');
const timing = {
windowMinutes: 10, // how far back each closed check looks, in minutes
checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table, in minutes
feedHours: 12, // how long a closed ticket stays in the feed
sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again
};
const app = express();
//creates the board service with the config specified
const boardService = createBoardService({
const zoho = createZohoAuth({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
tokenStore: createTokenStore(TOKEN_FILE),
technicians,
statusSet,
closedStatuses,
unassignedExcludedStatuses,
scope,
windowMinutes: WINDOW_MINUTES,
checkMinutes: CHECK_MINUTES,
workloadMinutes: WORKLOAD_MINUTES
redirectUri: `${PUBLIC_URL}/auth/callback`
});
app.use(express.static(path.join(__dirname, 'public')));
const sessions = createSessions({
secret: SESSION_SECRET,
// the proxy terminates https, so go by the public address rather than the request
secure: PUBLIC_URL.startsWith('https://'),
sessionDays: timing.sessionDays,
zoho
});
// weather widget is optional; it stays hidden unless both values are set
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE });
app.use('/api', createRoutes(boardService, weatherService, { adminKey: ADMIN_KEY }));
const app = express();
app.disable('x-powered-by');
// behind nginx proxy manager: believe X-Forwarded-For from private addresses (the docker network)
// so logs show the real client ip
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal');
if (!ADMIN_KEY) console.log('ADMIN_KEY not set: Connect and Refresh only work from this machine');
boardService.resume().catch(err => {
boardService.failConnection(err);
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin'
});
next();
});
// for the docker healthcheck; says nothing about sign-in or data
app.get('/healthz', (req, res) => res.send('ok'));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/auth', createAuthRoutes({ zoho, sessions }));
app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing }));
//starts listening on port specified
app.listen(PORT, () => {
console.log(`Open http://localhost:${PORT}`);
console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}`);
});

146
src/session.js Normal file
View File

@ -0,0 +1,146 @@
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
};

View File

@ -18,6 +18,11 @@ function getRelevantTicketTime(ticket) {
|| null;
}
// the user's token was rejected; let the route sign them out instead of showing it as a data error
function rethrowIfSignedOut(err) {
if (err.response?.status === 401) throw err;
}
function describeError(err) {
const detail = err.response?.data;
console.error('Error: ', detail || err.message);
@ -103,8 +108,8 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
try {
const windowStart = Date.now() - windowMinutes * 60 * 1000;
const data = await getRequests(accessToken, {
row_count: 200,
// paged, because the page asks for the last 12 hours on first load, not just a few minutes
const data = await getAllRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
fields_required: [
@ -126,9 +131,9 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
},
scopeCriteria(scope)
)
});
}, 'closed feed');
const found = data.requests || [];
const found = data.requests;
for (const ticket of found) {
if (!inScope(ticket, scope)) continue;
@ -168,6 +173,7 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
state.lastCheck = Date.now();
state.lastError = null;
} catch (err) {
rethrowIfSignedOut(err);
state.lastError = describeError(err);
state.lastCheck = Date.now();
}
@ -263,6 +269,7 @@ async function fetchWorkload({ accessToken, workload, technicians, closedStatuse
workload.updatedAt = Date.now();
workload.error = null;
} catch (err) {
rethrowIfSignedOut(err);
workload.error = describeError(err);
workload.updatedAt = Date.now();
}
@ -304,6 +311,7 @@ async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scop
unassigned.updatedAt = Date.now();
unassigned.error = null;
} catch (err) {
rethrowIfSignedOut(err);
unassigned.error = describeError(err);
unassigned.updatedAt = Date.now();
}

View File

@ -1,37 +0,0 @@
const fs = require('fs');
const path = require('path');
// keeps the Zoho tokens on disk so a restart doesn't need a new sign-in
function createTokenStore(filePath) {
function load() {
try {
const saved = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return saved && saved.refreshToken ? saved : null;
} catch (err) {
if (err.code !== 'ENOENT') console.error('Could not read saved token:', err.message);
return null;
}
}
function save(token) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// write then rename, so a crash mid-write can't leave a half-written file behind
const tmp = filePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ ...token, savedAt: Date.now() }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function clear() {
try {
fs.unlinkSync(filePath);
} catch (err) {
if (err.code !== 'ENOENT') console.error('Could not remove saved token:', err.message);
}
}
return { load, save, clear, filePath };
}
module.exports = {
createTokenStore
};