screentinker/server/middleware/auth.js
ScreenTinker 6dd78e078a refactor(auth): drop the unused optionalAuth middleware
optionalAuth was exported but never mounted on any route (verified by grep across
server, frontend, scripts and tests: only its own definition, its export, and one
stale comment referenced it). It also carried a second, slightly different copy of
the token-resolution logic - its own user column list, and no forced-password-change
check - which is exactly the drift the preceding commit consolidates away.

Removing it rather than porting it to resolveSessionUser: a "set req.user if a token
happens to be present" middleware is a few lines on top of the shared resolver if a
route ever needs one, and an unused export is a standing invitation to mount it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 21:03:25 -05:00

216 lines
11 KiB
JavaScript

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,
// recovery tokens) pass null and the tenancy resolver falls back to the
// user's first accessible workspace.
function generateToken(user, currentWorkspaceId) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, current_workspace_id: currentWorkspaceId || null },
config.jwtSecret,
{ algorithm: 'HS256', expiresIn: config.jwtExpiry }
);
}
// #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 (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', 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) {
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
// persisted; only exists for the lifetime of the request.
function recoveryUser(decoded) {
return {
id: decoded.id,
email: decoded.email || 'admin@localhost',
name: 'Recovery Admin',
role: decoded.role || 'platform_admin',
auth_provider: 'recovery',
avatar_url: null,
plan_id: 'enterprise'
};
}
// 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;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
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 {
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();
}
// (optionalAuth removed: it was exported but never mounted on any route, and it carried
// its own copy of the token-resolution logic - a different user column list, no forced-
// password-change check. A "set req.user if a token happens to be present" middleware is
// reintroducible on top of resolveSessionUser in a few lines if a route ever needs one.)
// Phase 2.1: role rename. Phase 1 renamed 'superadmin' to 'platform_admin' and
// dropped the in-between 'admin' role. These two guards are widened to accept
// either spelling so existing callers keep working without per-route edits.
// New code should prefer requirePlatformAdmin / requireOrgAdmin / workspace
// role guards from server/lib/permissions.js.
//
// Issue #14 (role normalization): the data migration in db/database.js collapses
// any legacy 'superadmin' -> 'platform_admin' and 'admin' -> 'user'. 'superadmin'
// is kept in PLATFORM_ROLES purely as back-compat belt-and-suspenders (recovery
// tokens, stray strings) - no row should carry it post-migration. Owner-level
// power lives here in PLATFORM_ROLES; anything not in this set is denied.
const PLATFORM_ROLES = ['superadmin', 'platform_admin'];
const ELEVATED_ROLES = ['admin', 'superadmin', 'platform_admin'];
// isPlatformRole: single predicate for "is this string a platform-owner role".
// Use this instead of a bare `role === 'platform_admin'` so a stray 'superadmin'
// is never silently treated as lower-privileged (the act-as bug fixed in #14).
// NOTE: this is the OWNER tier only - it deliberately does NOT include
// 'platform_operator' (issue #13), which is cross-org staff, not an owner.
function isPlatformRole(role) {
return PLATFORM_ROLES.includes(role);
}
// Issue #13: platform_operator is cross-org STAFF - it can see and act-as into
// every org and read/write workspace-scoped resources there, but holds NO
// owner-level power (no billing, no org/workspace deletion, no user/role
// management, no shared/template asset curation, no branding). The owner powers
// stay gated on PLATFORM_ROLES / isPlatformRole, which operator is deliberately
// NOT a member of - so every owner capability is deny-by-default for operators,
// and any NEW owner endpoint added later inherits that denial automatically.
//
// PLATFORM_STAFF / isPlatformStaff is the union used ONLY for cross-org
// VISIBILITY + act-as + workspace-scoped read/write. It must never gate an
// owner action.
const PLATFORM_STAFF = ['superadmin', 'platform_admin', 'platform_operator'];
function isPlatformStaff(role) {
return PLATFORM_STAFF.includes(role);
}
function requireAdmin(req, res, next) {
if (!req.user || !ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
function requireSuperAdmin(req, res, next) {
if (!req.user || !PLATFORM_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Platform admin access required' });
}
next();
}
// Preferred alias for new code.
const requirePlatformAdmin = requireSuperAdmin;
module.exports = { generateToken, generateMfaPendingToken, verifyToken, verifyMfaPendingToken, resolveSessionUser, SessionError, MFA_TOKEN_AUDIENCE, requireAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };