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:
parent
3e79a24efd
commit
b6ef6a195c
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,7 +10,6 @@ lerna-debug.log*
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
@ -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"
|
||||
|
||||
173
src/auth.js
173
src/auth.js
@ -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
|
||||
};
|
||||
|
||||
@ -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
|
||||
};
|
||||
@ -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=
|
||||
|
||||
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
|
||||
170
src/routes.js
170
src/routes.js
@ -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
|
||||
};
|
||||
|
||||
101
src/server.js
101
src/server.js
@ -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
146
src/session.js
Normal 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
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user