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

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

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

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