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>
91 lines
3.4 KiB
JavaScript
91 lines
3.4 KiB
JavaScript
const axios = require('axios');
|
|
|
|
// 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';
|
|
|
|
// 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 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}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
// 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 {
|
|
authorizeUrl,
|
|
exchangeCode,
|
|
refreshAccessToken,
|
|
revokeToken,
|
|
getUser
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
createZohoAuth
|
|
};
|