mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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>
230 lines
11 KiB
JavaScript
230 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();
|
|
}
|
|
|
|
// Optional auth - sets req.user if token present, continues either way
|
|
function optionalAuth(req, res, next) {
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
try {
|
|
const token = authHeader.split(' ')[1];
|
|
const decoded = verifyToken(token);
|
|
if (decoded.mfa_pending) return next(); // #100: pre-TOTP token is not a session
|
|
req.user = decoded.recovery
|
|
? recoveryUser(decoded)
|
|
: db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
|
|
req.jwtWorkspaceId = decoded.current_workspace_id || null;
|
|
} catch (err) {
|
|
// Token invalid, continue without user
|
|
}
|
|
}
|
|
next();
|
|
}
|
|
|
|
// 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, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };
|