refactor(auth): centralise session token resolution across manual verify sites

Six places verified a session JWT inline instead of going through requireAuth,
each repeating a slightly different subset of its checks. Introduce
resolveSessionUser() in middleware/auth.js as the single definition of "this
token is a usable session, and here is whose it is", and route all of them
through it: the three /api/status token routes, the screenshot route, the
content-reference gate, and the /dashboard socket handshake. requireAuth is now
a thin wrapper over the same helper, so the two cannot drift.

Also:
- Give the pre-TOTP token a distinct audience so it is redeemable only through
  verifyMfaPendingToken (POST /api/auth/totp/verify). verifyToken refuses any
  token carrying an audience, so a token minted for one purpose cannot be
  redeemed on another path.
- The dashboard socket handshake now takes userId/userRole from the live users
  row rather than from the token claim, so role changes take effect on the next
  connection instead of riding the token's remaining lifetime.
- Add test/session-token-resolution.test.js covering all six surfaces,
  including the socket handshake.

Every call site keeps the status code and error body it returned before.
Net query cost: the content-reference gate and the socket handshake each gain
one users-by-id lookup (the same one requireAuth already does per request); the
other four are unchanged or replace an equivalent lookup.

In-flight pre-TOTP tokens are invalidated by the audience change; they live 5
minutes, so the window is a re-login at worst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-07-24 20:36:52 -05:00
parent e3c7282bb7
commit c4b5a8679e
6 changed files with 447 additions and 79 deletions

View file

@ -2,6 +2,26 @@ const jwt = require('jsonwebtoken');
const config = require('../config');
const { db } = require('../db/database');
// Audience marker for the pre-TOTP token minted by generateMfaPendingToken below.
// Session tokens (generateToken, and the recovery token from scripts/reset-admin.js)
// carry NO `aud`, so verifyToken can refuse anything that does. That way a token minted
// for one narrow purpose can only be redeemed through its own accessor, and a future
// hand-rolled verify site that forgets a check fails CLOSED instead of accepting a
// half-authenticated token.
const MFA_TOKEN_AUDIENCE = 'st:mfa';
// Raised when a token is cryptographically valid but is not a usable session: the TOTP
// step is outstanding, the user row is gone, or a forced password change is pending.
// `code` lets each caller map the outcome onto the status/body it already returned, so
// no existing response shape changes.
class SessionError extends Error {
constructor(code, message) {
super(message || code);
this.name = 'SessionError';
this.code = code;
}
}
// Phase 2.1: JWT now optionally carries the user's current workspace_id so
// the tenancy middleware can resolve scope without an extra DB lookup on
// every request. Callers that don't know the workspace yet (legacy paths,
@ -17,19 +37,44 @@ function generateToken(user, currentWorkspaceId) {
// #100: issued after password verification but BEFORE the TOTP step, so the client
// can complete MFA. It is NOT a session token - it carries mfa_pending:true and is
// accepted ONLY by POST /api/auth/totp/verify. requireAuth/optionalAuth reject it
// (see below) - otherwise password-alone would yield a usable token and TOTP would
// be decorative. Short-lived.
// accepted ONLY by POST /api/auth/totp/verify (via verifyMfaPendingToken) - otherwise
// password-alone would yield a usable token and TOTP would be decorative. Short-lived.
// Two independent guards keep it off session paths: the mfa_pending check in
// resolveSessionUser, and the audience below (which verifyToken refuses outright, so
// even a caller that skips resolveSessionUser cannot accept this token).
function generateMfaPendingToken(user) {
return jwt.sign(
{ id: user.id, mfa_pending: true },
config.jwtSecret,
{ algorithm: 'HS256', expiresIn: '5m' }
{ algorithm: 'HS256', expiresIn: '5m', audience: MFA_TOKEN_AUDIENCE }
);
}
// Verify a SESSION token. Rejects any token carrying an audience: those are minted for a
// single narrower purpose and must go through their own accessor (verifyMfaPendingToken),
// never through a session path.
function verifyToken(token) {
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
const decoded = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
if (decoded && decoded.aud !== undefined) {
// The pre-TOTP audience is a KNOWN narrow purpose: report it as such so callers can
// still tell the client to complete MFA rather than "your token is broken". Any other
// audience is unrecognised here and refused generically - the fail-closed default.
const aud = Array.isArray(decoded.aud) ? decoded.aud : [decoded.aud];
if (aud.includes(MFA_TOKEN_AUDIENCE)) throw new SessionError('mfa_required');
throw new SessionError('invalid_audience', 'token is scoped to another purpose');
}
return decoded;
}
// The ONLY accepted path for a pre-TOTP token: POST /api/auth/totp/verify. Requires the
// audience, so a session token can't be presented here either.
function verifyMfaPendingToken(token) {
const decoded = jwt.verify(token, config.jwtSecret, {
algorithms: ['HS256'],
audience: MFA_TOKEN_AUDIENCE,
});
if (!decoded.mfa_pending) throw new SessionError('invalid_token', 'not a pre-TOTP token');
return decoded;
}
// Synthetic user record for recovery tokens (scripts/reset-admin.js). Not
@ -46,6 +91,38 @@ function recoveryUser(decoded) {
};
}
// THE single definition of "this token is a usable session, and here is whose it is".
// requireAuth below is a thin wrapper over it, and every site that verifies a JWT by
// hand calls it too (the status backup/export/import routes, the screenshot + content
// gates, the dashboard socket handshake) - so those checks cannot drift apart from
// requireAuth's again.
//
// Returns { user, decoded, viaRecovery }. Throws:
// - a jsonwebtoken error bad signature / expired / malformed
// - SessionError 'invalid_audience' token minted for a narrower purpose (pre-TOTP)
// - SessionError 'mfa_required' password accepted, TOTP step NOT completed. #100
// (tightening #1): if this check is missing, password-alone yields a working session
// and TOTP is decorative.
// - SessionError 'user_not_found' the token's user id no longer exists
// - SessionError 'password_change_required' #7: forced first-login change outstanding,
// enforced SERVER-SIDE so a provisioned temp password doesn't work indefinitely.
//
// allowPasswordChange lets requireAuth keep its two exempt endpoints (the change itself,
// PUT /api/auth/me, and logout) while every other caller stays hard-denied.
function resolveSessionUser(token, { allowPasswordChange = false } = {}) {
const decoded = verifyToken(token);
// Recovery identities are synthetic (scripts/reset-admin.js) and have no users row, so
// they skip the lookup. Callers that must not honour break-glass check viaRecovery.
if (decoded.recovery) return { user: recoveryUser(decoded), decoded, viaRecovery: true };
if (decoded.mfa_pending) throw new SessionError('mfa_required');
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id);
if (!user) throw new SessionError('user_not_found');
if (user.must_change_password && !allowPasswordChange) {
throw new SessionError('password_change_required');
}
return { user, decoded, viaRecovery: false };
}
// Express middleware - requires valid JWT
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
@ -53,38 +130,24 @@ function requireAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
}
// #7: while must_change_password is set, allow only reading/updating one's own profile
// (PUT /api/auth/me clears the flag) and logout; block everything else.
const url = (req.originalUrl || '').split('?')[0].replace(/\/$/, '');
const allowPasswordChange = url === '/api/auth/me' || url === '/api/auth/logout';
let session;
try {
const token = authHeader.split(' ')[1];
const decoded = verifyToken(token);
if (decoded.recovery) {
req.user = recoveryUser(decoded);
req.jwtWorkspaceId = null;
return next();
}
// #100 (tightening #1): an mfa_pending token has cleared the password but NOT the
// TOTP step. It must never authorize a protected route - only /api/auth/totp/verify
// accepts it. If this check is removed, password-alone yields a working session and
// TOTP is bypassed. (Covered by the mfa_pending bite-test.)
if (decoded.mfa_pending) return res.status(401).json({ error: 'mfa_required' });
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user;
// Tenancy middleware reads this on the resolver step.
req.jwtWorkspaceId = decoded.current_workspace_id || null;
// #7: enforce the forced first-login password change SERVER-SIDE (was a
// frontend-only redirect, so a provisioned temp password worked indefinitely
// via the API). While the flag is set, allow only reading/updating one's own
// profile (the password change is PUT /api/auth/me, which clears the flag)
// and logout; block everything else.
if (user.must_change_password) {
const url = (req.originalUrl || '').split('?')[0].replace(/\/$/, '');
const allowed = url === '/api/auth/me' || url === '/api/auth/logout';
if (!allowed) return res.status(403).json({ error: 'password_change_required' });
}
next();
session = resolveSessionUser(authHeader.split(' ')[1], { allowPasswordChange });
} catch (err) {
if (err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
if (err.code === 'user_not_found') return res.status(401).json({ error: 'User not found' });
if (err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
return res.status(401).json({ error: 'Invalid or expired token' });
}
req.user = session.user;
// Tenancy middleware reads this on the resolver step.
req.jwtWorkspaceId = session.viaRecovery ? null : (session.decoded.current_workspace_id || null);
next();
}
// Optional auth - sets req.user if token present, continues either way
@ -163,4 +226,4 @@ function requireSuperAdmin(req, res, next) {
// Preferred alias for new code.
const requirePlatformAdmin = requireSuperAdmin;
module.exports = { generateToken, generateMfaPendingToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };
module.exports = { generateToken, generateMfaPendingToken, verifyToken, verifyMfaPendingToken, resolveSessionUser, SessionError, MFA_TOKEN_AUDIENCE, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };

View file

@ -5,7 +5,7 @@ const https = require('https');
const { v4: uuidv4 } = require('uuid');
const { OAuth2Client } = require('google-auth-library');
const { db } = require('../db/database');
const { generateToken, generateMfaPendingToken, verifyToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth');
const { generateToken, generateMfaPendingToken, verifyMfaPendingToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const { logActivity, getClientIp } = require('../services/activity');
const totp = require('../lib/totp');
@ -351,7 +351,9 @@ router.post('/totp/verify', (req, res) => {
const { mfa_token, code } = req.body;
if (!mfa_token || !code) return res.status(400).json({ error: 'mfa_token and code required' });
let decoded;
try { decoded = verifyToken(mfa_token); } catch { return res.status(401).json({ error: 'mfa session expired' }); }
// verifyMfaPendingToken is the ONLY accessor that accepts the pre-TOTP audience; a full
// session token presented here is rejected by it (audience mismatch).
try { decoded = verifyMfaPendingToken(mfa_token); } catch { return res.status(401).json({ error: 'mfa session expired' }); }
if (!decoded.mfa_pending || !decoded.id) return res.status(401).json({ error: 'invalid mfa token' });
if (totpLockout.isLocked(decoded.id)) return res.status(429).json({ error: 'Too many invalid codes. Try again later.' });

View file

@ -6,7 +6,7 @@ const path = require('path');
const fs = require('fs');
const config = require('../config');
const VERSION = require('../version');
const { PLATFORM_ROLES } = require('../middleware/auth');
const { PLATFORM_ROLES, resolveSessionUser } = require('../middleware/auth');
const loopLag = require('../services/loop-lag');
// #146 P3.8: soak observability — internal limiter/maintenance states.
const flapLimiter = require('../lib/flap-limiter');
@ -61,19 +61,35 @@ function formatUptime(seconds) {
return `${m}m`;
}
// These three routes take the session token from the query string / Authorization header
// and resolve it themselves rather than sitting behind requireAuth. resolveSessionUser is
// the SAME resolver requireAuth uses, so they inherit every check it makes (pre-TOTP
// refusal, live user row, forced password change). Each keeps the exact status/body it
// returned before for the invalid-token case.
function denySession(res, err) {
if (err && err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
if (err && err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
return res.status(401).json({ error: 'Invalid token' });
}
// Full database backup (superadmin only)
router.get('/backup', (req, res) => {
const token = req.query.token;
if (!token) return res.status(401).json({ error: 'Token required' });
let session;
try {
const jwt = require('jsonwebtoken');
const config = require('../config');
const decoded = jwt.verify(token, config.jwtSecret);
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id);
if (!user || !PLATFORM_ROLES.includes(user.role)) return res.status(403).json({ error: 'Platform admin only' });
} catch {
return res.status(401).json({ error: 'Invalid token' });
session = resolveSessionUser(token);
} catch (err) {
// An unknown user id stays indistinguishable from "not a platform admin" (as before),
// so this endpoint never confirms whether a given id exists.
if (err.code === 'user_not_found') return res.status(403).json({ error: 'Platform admin only' });
return denySession(res, err);
}
// A break-glass identity has no users row, so it could never pass the role check here
// before; keep it that way rather than letting the synthetic role claim decide.
if (session.viaRecovery || !PLATFORM_ROLES.includes(session.user.role)) {
return res.status(403).json({ error: 'Platform admin only' });
}
const dbPath = require('../config').dbPath;
@ -88,16 +104,19 @@ router.get('/export', (req, res) => {
let userId;
let workspaceId;
try {
const jwt = require('jsonwebtoken');
const config = require('../config');
const decoded = jwt.verify(token, config.jwtSecret);
userId = decoded.id;
workspaceId = decoded.current_workspace_id || null;
const session = resolveSessionUser(token);
// For a break-glass identity this is the synthetic recovery id, which has no users
// row - the lookup below then 404s exactly as the inline verify did before.
userId = session.user.id;
workspaceId = session.decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch {
return res.status(401).json({ error: 'Invalid token' });
} catch (err) {
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
return denySession(res, err);
}
// Re-read with the export's own column list (it needs created_at, which the session
// resolver doesn't select).
const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'User not found' });
@ -219,19 +238,18 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
let userId;
let workspaceId;
try {
const jwt = require('jsonwebtoken');
const jwtConfig = require('../config');
const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret);
userId = decoded.id;
workspaceId = decoded.current_workspace_id || null;
const session = resolveSessionUser(authHeader.split(' ')[1]);
// A break-glass identity has no users row: the lookup this replaced returned nothing
// for it, so the route 404'd. Preserve that.
if (session.viaRecovery) return res.status(404).json({ error: 'User not found' });
userId = session.user.id;
workspaceId = session.decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch {
return res.status(401).json({ error: 'Invalid token' });
} catch (err) {
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
return denySession(res, err);
}
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'User not found' });
// Phase 2.2b: imports stamp workspace_id on devices and content so the
// rows are visible to the workspace-filtered list endpoints. Fall back to
// the importer's first accessible workspace if the JWT didn't carry one.

View file

@ -410,19 +410,26 @@ app.use('/api/stripe', stripeRouter);
// Screenshot route (before protected routes - needs custom auth for img tags)
const { verifyToken } = require('./middleware/auth');
const { resolveSessionUser } = require('./middleware/auth');
app.get('/api/devices/:id/screenshot', (req, res) => {
let user = null;
const authHeader = req.headers.authorization;
const tokenParam = req.query.token;
const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : tokenParam;
if (!token) return res.status(401).json({ error: 'Authentication required' });
// resolveSessionUser is the same resolver requireAuth uses, so this route inherits the
// pre-TOTP refusal, the live-user check and the forced-password-change gate.
try {
const decoded = verifyToken(token);
const { db } = require('./db/database');
user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' });
} catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
const session = resolveSessionUser(token);
// Break-glass has no users row; the lookup this replaced returned nothing for it.
if (session.viaRecovery) return res.status(401).json({ error: 'User not found' });
user = session.user;
} catch (err) {
if (err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
if (err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
if (err.code === 'user_not_found') return res.status(401).json({ error: 'User not found' });
return res.status(401).json({ error: 'Invalid or expired token' });
}
const { db: sdb } = require('./db/database');
const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' });
@ -453,13 +460,17 @@ function requesterCanAccessContent(req, content) {
try {
const m = (req.headers.authorization || '').match(/^Bearer (.+)$/);
if (!m) return false;
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(m[1], config.jwtSecret, { algorithms: ['HS256'] });
if (!decoded || !decoded.id) return false;
if (decoded.role === 'platform_admin') return true;
// Same resolver as requireAuth: a pre-TOTP token, a deleted user or an outstanding
// forced password change all throw here and fall through to false.
const session = resolveSessionUser(m[1]);
if (session.viaRecovery) return false; // no workspace membership; unchanged behaviour
const user = session.user;
if (!user || !user.id) return false;
// Role from the LIVE users row, not the token claim, so a demotion takes effect at once.
if (user.role === 'platform_admin') return true;
const { db } = require('./db/database');
return !!db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
.get(content.workspace_id, decoded.id);
.get(content.workspace_id, user.id);
} catch { return false; }
}

View file

@ -0,0 +1,263 @@
'use strict';
// Six surfaces resolve a session token THEMSELVES instead of sitting behind requireAuth:
// the three /api/status token routes, the screenshot route, the content-reference gate,
// and the /dashboard socket handshake. They all now call middleware/auth.resolveSessionUser,
// the same resolver requireAuth uses, so they must inherit its checks. This suite walks
// every one of them and asserts, per surface:
//
// 1. a PRE-TOTP token (mfa_pending, i.e. password accepted but the TOTP step not
// completed) is refused,
// 2. a normal full session token is still accepted, and
// 3. a recovery token behaves as it did before this refactor - accepted by
// requireAuth-gated routes, refused by these six (it has no users row, so the
// lookups these replaced already denied it).
//
// The socket handshake is included deliberately: it is the one surface with no HTTP
// equivalent, so a suite that covered only the routes would not hold it to the contract.
//
// Boots the REAL server.js as a subprocess against an isolated DB (same convention as
// api.test.js / totp.test.js). Node built-ins + socket.io-client (devDep) only.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const jwt = require('jsonwebtoken');
const ioClient = require('socket.io-client');
const { authenticator } = require('otplib');
const { freePort } = require('./helpers/free-port');
let PORT, BASE;
const SECRET = 'test-secret-session-resolution-' + crypto.randomBytes(4).toString('hex');
const DATA_DIR = path.join(os.tmpdir(), 'st-session-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-session-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc;
const S = {}; // shared fixtures populated in before()
const PW = 'Passw0rd123';
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const auth = (tok, extra = {}) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json', ...extra } });
const post = (tok, obj, extra) => ({ method: 'POST', ...auth(tok, extra), body: JSON.stringify(obj || {}) });
// A 1x1 PNG - enough for the ingest path to produce a real file + thumbnail, so the
// content-reference gate is actually reachable.
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8AAAwAB/gGVUvXhAAAAAElFTkSuQmCC',
'base64'
);
// Connect to the /dashboard namespace and report whether the handshake was accepted.
function connectDashboard(token) {
return new Promise((resolve) => {
const sock = ioClient(`${BASE}/dashboard`, {
auth: { token }, transports: ['websocket'], reconnection: false, forceNew: true,
});
let settled = false;
const done = (r) => { if (settled) return; settled = true; try { sock.close(); } catch { /* */ } resolve(r); };
sock.on('connect', () => done({ connected: true, message: null }));
sock.on('connect_error', (e) => done({ connected: false, message: e.message }));
setTimeout(() => done({ connected: false, message: 'timeout' }), 5000);
});
}
before(async () => {
PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test', JWT_SECRET: SECRET },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
// First registered user becomes platform_admin - needed so the /backup happy path is
// actually authorized, which is what makes its pre-TOTP refusal meaningful.
S.adminEmail = 'admin' + crypto.randomBytes(4).toString('hex') + '@x.local';
const reg = await jfetch('/api/auth/register', post(null, { email: S.adminEmail, password: PW }));
S.adminToken = reg.body.token;
assert.equal(reg.body.user.role, 'platform_admin', 'first user is platform_admin');
// Enroll TOTP, then log in again to obtain a genuine mfa_pending token. The pre-existing
// session token stays valid across enrollment (see totp.test.js), so S.adminToken is
// still our "full session" fixture for the same account.
const setup = await jfetch('/api/auth/totp/setup', post(S.adminToken, {}));
await jfetch('/api/auth/totp/enable', post(S.adminToken, { code: authenticator.generate(setup.body.secret) }));
const login = await jfetch('/api/auth/login', post(null, { email: S.adminEmail, password: PW }));
assert.equal(login.body.mfa_required, true, 'login now stops at the TOTP step');
assert.equal(login.body.token, undefined, 'no full session token before TOTP');
S.mfaToken = login.body.mfa_token;
assert.ok(S.mfaToken, 'got a pre-TOTP token');
// Recovery token, minted exactly as scripts/reset-admin.js does it.
S.recoveryToken = jwt.sign(
{ id: 'recovery-' + crypto.randomBytes(8).toString('hex'), email: 'admin@localhost', role: 'admin', recovery: true },
SECRET, { expiresIn: '1h' }
);
// Content with a real file + thumbnail, not referenced by any playlist, so
// /api/content/:id/{file,thumbnail} falls through to the requester gate.
const fd = new FormData();
fd.append('file', new Blob([PNG_1X1], { type: 'image/png' }), 'px.png');
const up2 = await fetch(BASE + '/api/content', { method: 'POST', headers: { Authorization: 'Bearer ' + S.adminToken }, body: fd });
const created = await up2.json();
S.contentId = created.id;
assert.ok(S.contentId, 'uploaded content for the reference-gate tests');
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } });
// ---------------------------------------------------------------------------
// 1. GET /api/status/backup (?token=) - full database download
// ---------------------------------------------------------------------------
test('status/backup: pre-TOTP token refused, full admin session accepted, recovery refused', async () => {
const mfa = await jfetch(`/api/status/backup?token=${encodeURIComponent(S.mfaToken)}`);
assert.equal(mfa.status, 401, 'pre-TOTP token must not reach the database backup');
assert.equal(mfa.body.error, 'mfa_required');
// Happy path returns the DB file itself, so consume it rather than parsing JSON.
const ok = await fetch(`${BASE}/api/status/backup?token=${encodeURIComponent(S.adminToken)}`);
await ok.arrayBuffer();
assert.equal(ok.status, 200, 'a full platform-admin session still downloads the backup');
const rec = await jfetch(`/api/status/backup?token=${encodeURIComponent(S.recoveryToken)}`);
assert.equal(rec.status, 403, 'recovery identity is not a platform admin here (unchanged)');
});
// ---------------------------------------------------------------------------
// 2. GET /api/status/export (?token=)
// ---------------------------------------------------------------------------
test('status/export: pre-TOTP token refused, full session accepted, recovery refused', async () => {
const mfa = await jfetch(`/api/status/export?token=${encodeURIComponent(S.mfaToken)}`);
assert.equal(mfa.status, 401, 'pre-TOTP token must not export account data');
assert.equal(mfa.body.error, 'mfa_required');
const ok = await jfetch(`/api/status/export?token=${encodeURIComponent(S.adminToken)}`);
assert.equal(ok.status, 200, 'a full session still exports');
const rec = await jfetch(`/api/status/export?token=${encodeURIComponent(S.recoveryToken)}`);
assert.equal(rec.status, 404, 'recovery identity has no users row (unchanged)');
});
// ---------------------------------------------------------------------------
// 3. POST /api/status/import (Authorization header)
// ---------------------------------------------------------------------------
test('status/import: pre-TOTP token refused, full session reaches the handler, recovery refused', async () => {
const mfa = await jfetch('/api/status/import', post(S.mfaToken, { format: 'screentinker-export-v2' }));
assert.equal(mfa.status, 401, 'pre-TOTP token must not import into a workspace');
assert.equal(mfa.body.error, 'mfa_required');
// 400 here is the post-auth validation failing on a body with no payload - i.e. the
// token WAS accepted. Asserting "not 401/403/404" is the auth boundary we care about.
const ok = await jfetch('/api/status/import', post(S.adminToken, { nope: true }));
assert.equal(ok.status, 400, 'a full session passes auth and fails validation instead');
const rec = await jfetch('/api/status/import', post(S.recoveryToken, { nope: true }));
assert.equal(rec.status, 404, 'recovery identity has no users row (unchanged)');
});
// ---------------------------------------------------------------------------
// 4. GET /api/devices/:id/screenshot (?token= and header)
// ---------------------------------------------------------------------------
test('screenshot: pre-TOTP token refused before the device lookup; full session gets past auth', async () => {
const id = crypto.randomUUID();
const mfa = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.mfaToken)}`);
assert.equal(mfa.status, 401, 'pre-TOTP token must not read device screenshots');
assert.equal(mfa.body.error, 'mfa_required');
// No device exists, so 404 "Device not found" is the proof that auth was accepted and
// the handler moved on to its (deliberately untouched) ownership logic.
const ok = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.adminToken)}`);
assert.equal(ok.status, 404, 'a full session clears auth and reaches the device lookup');
const rec = await jfetch(`/api/devices/${id}/screenshot?token=${encodeURIComponent(S.recoveryToken)}`);
assert.equal(rec.status, 401, 'recovery identity has no users row (unchanged)');
// Same outcome via the Authorization header, not just the query parameter.
const hdr = await jfetch(`/api/devices/${id}/screenshot`, auth(S.mfaToken));
assert.equal(hdr.status, 401, 'header path refuses the pre-TOTP token too');
});
// ---------------------------------------------------------------------------
// 5. requesterCanAccessContent -> /api/content/:id/{file,thumbnail}
// ---------------------------------------------------------------------------
test('content gate: pre-TOTP token gets no more than an anonymous caller; full session reads', async () => {
// Baseline: unreferenced content is not public.
const anon = await jfetch(`/api/content/${S.contentId}/thumbnail`);
assert.equal(anon.status, 403, 'unreferenced content is not anonymously readable');
// The gate returns a boolean, so a refused token surfaces as the same 403 an anonymous
// caller gets - the point is that it grants NOTHING extra.
const mfa = await jfetch(`/api/content/${S.contentId}/thumbnail`, auth(S.mfaToken));
assert.equal(mfa.status, 403, 'pre-TOTP token unlocks no content');
const mfaFile = await jfetch(`/api/content/${S.contentId}/file`, auth(S.mfaToken));
assert.equal(mfaFile.status, 403, 'pre-TOTP token unlocks no file either');
const okThumb = await fetch(`${BASE}/api/content/${S.contentId}/thumbnail`, auth(S.adminToken));
await okThumb.arrayBuffer();
assert.equal(okThumb.status, 200, 'a workspace member with a full session still reads the thumbnail');
const okFile = await fetch(`${BASE}/api/content/${S.contentId}/file`, auth(S.adminToken));
await okFile.arrayBuffer();
assert.equal(okFile.status, 200, 'and the file');
const rec = await jfetch(`/api/content/${S.contentId}/thumbnail`, auth(S.recoveryToken));
assert.equal(rec.status, 403, 'recovery identity has no workspace membership (unchanged)');
});
// ---------------------------------------------------------------------------
// 6. /dashboard socket handshake
// ---------------------------------------------------------------------------
test('dashboard socket: pre-TOTP token cannot open the namespace; full session can', async () => {
const mfa = await connectDashboard(S.mfaToken);
assert.equal(mfa.connected, false, 'pre-TOTP token must not reach the device-command channel');
assert.equal(mfa.message, 'mfa_required');
const ok = await connectDashboard(S.adminToken);
assert.equal(ok.connected, true, 'a full session still connects');
const rec = await connectDashboard(S.recoveryToken);
assert.equal(rec.connected, false, 'recovery identity is refused the handshake');
const bogus = await connectDashboard('not-a-jwt');
assert.equal(bogus.connected, false, 'garbage token still refused');
assert.equal(bogus.message, 'Invalid token');
});
// ---------------------------------------------------------------------------
// Cross-checks: the audience split, and requireAuth non-regression.
// ---------------------------------------------------------------------------
test('audience split: the pre-TOTP token works ONLY at /totp/verify, and a session token does not', async () => {
// A full session token presented as an mfa_token is rejected (no pre-TOTP audience).
const wrongWay = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: S.adminToken, code: '000000' }));
assert.equal(wrongWay.status, 401, 'a session token is not accepted as an mfa_token');
assert.equal(wrongWay.body.error, 'mfa session expired');
// And the pre-TOTP token is still accepted there: a wrong code gets past token
// validation to the code check (401 "Invalid code"), not rejected as a bad token.
const rightWay = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: S.mfaToken, code: '000000' }));
assert.equal(rightWay.status, 401);
assert.equal(rightWay.body.error, 'Invalid code', 'pre-TOTP token is still redeemable at /totp/verify');
});
test('requireAuth non-regression: full session works, pre-TOTP 401s, recovery still reaches a gated route', async () => {
assert.equal((await jfetch('/api/auth/me', auth(S.adminToken))).status, 200, 'full session');
const mfa = await jfetch('/api/auth/me', auth(S.mfaToken));
assert.equal(mfa.status, 401, 'pre-TOTP token');
assert.equal(mfa.body.error, 'mfa_required');
// Break-glass must keep working where it worked before - a requireAuth-gated route.
assert.equal((await jfetch('/api/devices', auth(S.recoveryToken))).status, 200, 'recovery still authenticates');
});

View file

@ -1,5 +1,5 @@
const heartbeat = require('../services/heartbeat');
const { verifyToken } = require('../middleware/auth');
const { resolveSessionUser } = require('../middleware/auth');
const { db } = require('../db/database');
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
const { workspaceRoom } = require('../lib/socket-rooms');
@ -38,14 +38,25 @@ module.exports = function setupDashboardSocket(io) {
dashboardNs.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (!token) return next(new Error('Authentication required'));
let session;
try {
const decoded = verifyToken(token);
socket.userId = decoded.id;
socket.userRole = decoded.role;
next();
} catch {
next(new Error('Invalid token'));
// Same resolver as requireAuth, so the socket inherits the pre-TOTP refusal and the
// forced-password-change gate that the HTTP surface enforces.
session = resolveSessionUser(token);
} catch (err) {
if (err.code === 'mfa_required') return next(new Error('mfa_required'));
if (err.code === 'password_change_required') return next(new Error('password_change_required'));
return next(new Error('Invalid token'));
}
// Break-glass identities have no users row and no workspace membership, so
// canActOnDevice -> accessContext already denied them every command. Refuse the
// handshake rather than hold open a socket that can do nothing.
if (session.viaRecovery) return next(new Error('Invalid token'));
socket.userId = session.user.id;
// Role + existence come from the LIVE users row, not the token claim: a deleted or
// demoted user no longer keeps fleet control for the remainder of a 7-day JWT.
socket.userRole = session.user.role;
next();
});
dashboardNs.on('connection', (socket) => {