Limit sign-in to allowed email domains
All checks were successful
Build and push image / build (push) Successful in 13s

Any Zoho account can approve the client, and an outside account got a
session that returned the .env site, group and technician names.
ALLOWED_EMAIL_DOMAINS is now required: the callback refuses other
domains and revokes their token, and every API call re-checks it so
older cookies stop working. /api/names also makes the caller's token
read from SDP before returning the shared names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Skylar Grant 2026-10-15 21:29:25 -04:00
parent 817fa47edd
commit 2f137afd5f
10 changed files with 54 additions and 8 deletions

View File

@ -25,6 +25,7 @@ Copy `src/env.example` to a file named `.env` and fill it in. The region, timing
| --- | --- | --- |
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Server-based Application credentials. |
| `SESSION_SECRET` | Yes | At least 32 characters. Encrypts the sign-in cookie. Generate one with `openssl rand -hex 32`. Changing it signs everyone out. |
| `ALLOWED_EMAIL_DOMAINS` | Yes | Comma-separated email domains allowed to sign in, e.g. `mainecc.edu`. Any Zoho user can approve the client, so this is what keeps outside Zoho accounts out. Exact match: `mainecc.edu` doesn't let in `sub.mainecc.edu`. Checked at sign-in and on every call, so removing a domain signs those people out. The server won't start without it. |
| `PUBLIC_URL` | Yes when hosted | The address people open, with no trailing slash, e.g. `https://dashboard.example.com`. Zoho sends people back to `PUBLIC_URL/auth/callback`. Defaults to `http://localhost:3000`. Cookies are marked secure when it starts with `https://`. |
| `SESSION_DAYS` | No | How many days a sign-in lasts before Zoho is asked again. Defaults to 7. |
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
@ -108,6 +109,7 @@ The sign-in is kept in an encrypted cookie in that browser for `SESSION_DAYS` (7
### Security
- **Network:** NPM only lets in MCCS' public IP addresses (campuses and VPN). From anywhere else the subdomain doesn't answer.
- **Allowed domains:** any Zoho account can complete the Zoho consent screen, so the server only creates a session for emails at `ALLOWED_EMAIL_DOMAINS`, and checks again on every call. A refused sign-in has its Zoho token revoked straight away.
- **Per-user sign-in:** every request needs the user's own Zoho sign-in. There is no shared token, so a reachable page with no sign-in shows nothing.
- **Tokens:** the Zoho tokens live only in the user's cookie, encrypted and authenticated with `SESSION_SECRET` (AES-256-GCM). The cookie is `HttpOnly`, so page scripts can't read it, and `SameSite=Lax`. The server doesn't store tokens or sessions.
- **Read-only:** the only SDP scope is `SDPOnDemand.requests.READ`.

View File

@ -40,6 +40,8 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 has this gap. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
- **2026-10-15:** **`/api/names` makes the caller prove SDP access first** (a one-row requests query with their token) before returning the shared cache, so the names never go to a token SDP refuses.
- **2026-10-15:** **No published ports in compose.** Docker's port publishing bypasses ufw, which would skip NPM's IP access list. The container joins NPM's network instead.
- **2026-10-15:** **CI uses plain `docker` commands** on the self-hosted runner, not the buildx or login actions, so there are fewer moving parts on the runner. Pushes both the tag and `latest`.

View File

@ -16,6 +16,7 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
- [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain.
- [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`.
- [ ] **Technician check at sign-in.** Replace or back up `ALLOWED_EMAIL_DOMAINS` by asking SDP whether the user is a technician. Find the scope this needs (users/technicians read); adding it means everyone signs in again. It would also allow real pick lists for technicians.
- [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model.
- [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`).

View File

@ -1,6 +1,9 @@
# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback
CLIENT_ID=
CLIENT_SECRET=
# required: only Zoho accounts with an email at these domains can sign in (exact match, comma-separated)
# any Zoho user can approve the client, so this is what keeps outside accounts out
ALLOWED_EMAIL_DOMAINS=mainecc.edu
# 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

View File

@ -1,6 +1,6 @@
const path = require('path');
const { createJsonFile } = require('./store');
const { fetchRecentNames } = require('./tickets');
const { fetchRecentNames, checkRequestAccess } = require('./tickets');
const REFRESH_HOURS = 1; // SDP is asked for fresh names at most this often, whoever asks
const LOOKBACK_DAYS = 30;
@ -26,8 +26,11 @@ function createNameCache({ dataDir, seed }) {
}
}
// the user's token is used to refresh; if SDP says no, the cached names are still returned
// The cache is only handed out once the caller's own token has read from SDP, so the names
// never reach someone SDP turns away. That token also refreshes the cache when it's due; a
// failed refresh still returns the cached names, since the caller has already proved access.
async function list(accessToken) {
await checkRequestAccess(accessToken);
if (Date.now() - file.get().fetchedAt > REFRESH_HOURS * 60 * 60 * 1000) {
pending = pending || fetchRecentNames({ accessToken, days: LOOKBACK_DAYS, pages: PAGES })
.then(found => {

View File

@ -736,6 +736,7 @@ const STATUS_TEXT = {
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.',
notallowed: "That Zoho account isn't allowed to use this dashboard. Sign in with your MCCS staff account.",
failed: "Zoho sign-in didn't complete. Try again, and check the server log if it keeps happening."
};

View File

@ -22,6 +22,14 @@ function createAuthRoutes({ zoho, sessions }) {
try {
const tokens = await zoho.exchangeCode(String(req.query.code));
const user = await zoho.getUser(tokens.accessToken);
if (!sessions.isAllowed(user)) {
console.warn(`Sign-in refused, not an allowed domain: ${user.email || 'no email'} from ${req.ip}`);
// they never get a session, so drop the token Zoho just issued
await zoho.revokeToken(tokens.refreshToken).catch(err => {
console.warn('Could not revoke Zoho token:', err.message);
});
return res.redirect('/?signin=notallowed');
}
sessions.start(res, tokens, user);
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
res.redirect('/');
@ -123,7 +131,15 @@ function createApiRoutes({ sessions, weatherService, settings, timing, userSetti
// suggestions for the settings panel; typing any name still works
router.get('/names', signedOutOnSdp401(async (req, res) => {
res.json(await nameCache.list(req.session.accessToken));
let names;
try {
names = await nameCache.list(req.session.accessToken);
} catch (err) {
if (err.response?.status === 401) throw err;
console.warn(`Name suggestions refused for ${req.session.user?.email}:`, err.response?.status || err.message);
return res.status(403).json({ error: 'ServiceDesk Plus did not allow this request' });
}
res.json(names);
}));
router.get('/closed', signedOutOnSdp401(async (req, res) => {

View File

@ -18,17 +18,21 @@ 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(/\/+$/, '');
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
// any Zoho account can approve our client, so sign-in alone doesn't mean someone is staff
const allowedDomains = toList(process.env.ALLOWED_EMAIL_DOMAINS).map(d => d.replace(/^@/, '').toLowerCase());
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
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null,
!allowedDomains.length ? 'ALLOWED_EMAIL_DOMAINS is required, e.g. mainecc.edu' : null
].filter(Boolean);
if (problems.length) {
problems.forEach(p => console.error(p));
process.exit(1);
}
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
const technicians = toList(TECHNICIAN_NAMES);
const statuses = toList(STATUS_NAMES);
// statuses that count as done; everything else shows up in the workload table
@ -60,6 +64,11 @@ 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://'),
// exact domain match on the email Zoho reports; no email means no entry
isAllowed: user => {
const email = (user?.email || '').toLowerCase();
return allowedDomains.some(domain => email.endsWith('@' + domain));
},
sessionDays: timing.sessionDays,
zoho
});

View File

@ -22,7 +22,7 @@ function parseCookies(header) {
// 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 }) {
function createSessions({ secret, secure, sessionDays, zoho, isAllowed }) {
const key = crypto.createHash('sha256').update(secret).digest();
const maxAgeMs = sessionDays * 24 * 60 * 60 * 1000;
const cookieBase = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
@ -92,7 +92,9 @@ function createSessions({ secret, secure, sessionDays, zoho }) {
// 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) {
// checked on every call, not just at sign-in, so cookies issued before the
// domain rule (or before a domain is removed) stop working too
if (!session || !isAllowed(session.user)) {
clear(res);
return res.status(401).json({ error: 'Not signed in' });
}
@ -132,6 +134,7 @@ function createSessions({ secret, secure, sessionDays, zoho }) {
}
return {
isAllowed,
read,
start,
clear,

View File

@ -344,7 +344,13 @@ async function fetchRecentNames({ accessToken, days, pages }) {
};
}
// one row, to prove a token can read this portal's requests before handing out cached names
async function checkRequestAccess(accessToken) {
await getRequests(accessToken, { row_count: 1, fields_required: ['display_id'] });
}
module.exports = {
checkRequestAccess,
fetchClosedTickets,
fetchWorkload,
fetchUnassigned,