screentinker/server/middleware/auth.js
ScreenTinker f289609380 fix(auth): back break-glass recovery with a revocable, auditable grant
scripts/reset-admin.js mints a JWT carrying `recovery: true`, and middleware/auth.js
accepted that claim on its own with no database involvement. Three consequences:

- NOT REVOCABLE. The only way to invalidate an outstanding recovery token was to rotate
  JWT_SECRET, which logs out every user on the instance.
- NOT ENUMERABLE. Nobody could answer "is a recovery token outstanding right now?"
- NOT AUDITED. The synthetic id ('recovery-<nonce>') is not a users row, so every
  activity_log insert for it failed the user_id foreign key and was swallowed by a catch —
  a break-glass session left no trace at all.

A `recovery_grants` row per minted token turns all three around: DELETE revokes, SELECT
enumerates, expires_at bounds, and used_at + source_ip record when and from where it was
first exercised. The migration is additive and idempotent, so re-running is a no-op and a
code-only rollback just leaves an unused table.

The grant is session-scoped, NOT single-use-per-request. Recovery means many requests —
load the dashboard, list users, reset a password — so consuming the grant on the first
would make break-glass unusable, a worse outcome than the narrow replay window it closes.
Revocation and expiry are the controls; used_at is the audit stamp.

Also fixed, because it is the mechanism that hid this: logActivity now rewrites a
'recovery-*' id to a NULL user_id with the identity in `details`, so break-glass actions
are actually recorded instead of failing the FK; and a dropped audit row now logs a loud
[AUDIT-DROP] line naming the action and increments a counter, rather than vanishing into
console.error.

The token is written to a 0600 file instead of stdout — under systemd or Docker, printing
it meant journald captured a live admin credential well past its lifetime. Added --list
and --revoke-all.

In-flight recovery tokens minted before this change stop working; they live one hour and
were unrevocable, which is the problem being fixed. Minting already required a working DB,
so redeeming against one is not a new dependency.

test/session-token-resolution.test.js now mints a real grant for its recovery token, so
its assertions keep testing that break-glass is refused on those surfaces for lack of a
users row — not for the unrelated new reason that the token is invalid.

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

228 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, sourceIp = null } = {}) {
const decoded = verifyToken(token);
// Recovery identities are synthetic (scripts/reset-admin.js) and have no users row, so
// they skip the users lookup — but they are NOT accepted on the strength of the claim
// alone. A `recovery: true` JWT is only honoured while a matching grant row exists,
// unexpired and unused (lib/recovery-grant), which is what makes break-glass revocable
// (DELETE the row), enumerable, and single-use. Redemption stamps used_at, so the same
// token cannot be replayed.
if (decoded.recovery) {
const grants = require('../lib/recovery-grant');
if (!decoded.jti || !grants.redeem(decoded.jti, { sourceIp })) {
throw new SessionError('recovery_grant_invalid');
}
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, sourceIp: req.ip || null });
} catch (err) {
if (err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
// No grant, spent, expired or revoked — indistinguishable from any other bad token.
if (err.code === 'recovery_grant_invalid') return res.status(401).json({ error: 'Invalid or expired token' });
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 };