From c4b5a8679eac4c22f5046af6cf1df34c76196d8a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 24 Jul 2026 20:36:52 -0500 Subject: [PATCH] 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) --- server/middleware/auth.js | 131 ++++++--- server/routes/auth.js | 6 +- server/routes/status.js | 68 +++-- server/server.js | 33 ++- server/test/session-token-resolution.test.js | 263 +++++++++++++++++++ server/ws/dashboardSocket.js | 25 +- 6 files changed, 447 insertions(+), 79 deletions(-) create mode 100644 server/test/session-token-resolution.test.js diff --git a/server/middleware/auth.js b/server/middleware/auth.js index d199c37..6ea6260 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -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 }; diff --git a/server/routes/auth.js b/server/routes/auth.js index 59c0ebc..251791c 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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.' }); diff --git a/server/routes/status.js b/server/routes/status.js index eca3274..9449f65 100644 --- a/server/routes/status.js +++ b/server/routes/status.js @@ -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. diff --git a/server/server.js b/server/server.js index 4133ba9..6410459 100644 --- a/server/server.js +++ b/server/server.js @@ -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; } } diff --git a/server/test/session-token-resolution.test.js b/server/test/session-token-resolution.test.js new file mode 100644 index 0000000..48668f0 --- /dev/null +++ b/server/test/session-token-resolution.test.js @@ -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'); +}); diff --git a/server/ws/dashboardSocket.js b/server/ws/dashboardSocket.js index fb160f6..b1bf844 100644 --- a/server/ws/dashboardSocket.js +++ b/server/ws/dashboardSocket.js @@ -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) => {