Files
website/server/src/auth/session.service.js
wtclaude 60ebacff2c
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
feat(auth): trusted devices, recovery codes, and admin MFA management
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.

- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
  recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
  httpOnly cookie (survives logout, revoked on untrust/password change/reset/
  TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
  gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
  over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
  /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
  code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
  revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:38:48 -05:00

332 lines
15 KiB
JavaScript

// ── Session service ────────────────────────────────────────────────────────
//
// The single seam every caller goes through to issue and validate a session.
// Today a "session" is a signed JWT (cookie for web, or a Bearer token), but
// callers only ever see the abstract Session object below — never the raw token
// shape. That indirection is what lets Part 2 (mobile bearer tokens) and Part 3
// (SSO) add new `authMethod`s without touching controllers or middleware.
//
// A Session object:
// {
// sessionId, // stable id for this session (JWT jti)
// userId, // the user's DB id
// username,
// role,
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
// createdAt, // ms epoch the token was issued (JWT iat)
// expiresAt, // ms epoch the token expires (JWT exp), or null
// lastSeenAt, // ms epoch this session was last validated
// }
//
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
// denylist (revoked_sessions — single logout) and a per-user cutoff
// (users.tokens_valid_after — password change / log out everywhere). requireAuth
// consults both. The functions here are the seam the controllers call.
const crypto = require('crypto')
const token = require('./token')
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
const trustedDevices = require('../model/trustedDevices/trustedDevices.model')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
// bearer flow (Part 2); 'google'/'discord'/'oidc' are SSO providers and 'sso' is
// the generic fallback label (Part 3). Sessions are tagged by how they were
// authenticated without changing this module per provider.
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
// The claim that positively marks a token as a real, full session. Every JWT in
// the app is signed with the same secret and is distinguished only by claims, so
// a session must be identified by what it *is* (typ === 'session'), never by the
// mere absence of some other marker. Only the session-minting paths below stamp
// it; flow/challenge tokens (the TOTP challenge, the SSO transaction cookie) do
// not, so — even though they verify against the same secret — they can never be
// mistaken for a session. See issue #32 (sso_tx token-type confusion).
const SESSION_TYP = 'session'
// Build a Session object from a decoded JWT payload. Returns null for anything
// that is not a full session. Validation is positively typed: a token qualifies
// only if it was explicitly minted as a session. As belt-and-suspenders we also
// reject any token carrying a non-session marker (stage = TOTP challenge, kind =
// SSO transaction), so a future minting path that forgets to omit those still
// can't produce an accepted session.
function sessionFromDecoded(decoded, now = Date.now()) {
if (!decoded || decoded.typ !== SESSION_TYP) return null
if (decoded.stage || decoded.kind) return null
return {
sessionId: decoded.jti || null,
userId: decoded.id,
username: decoded.username,
role: decoded.role,
authMethod: decoded.authMethod || 'local',
createdAt: decoded.iat ? decoded.iat * 1000 : null,
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
lastSeenAt: now,
}
}
// Issue a real session for a fully-authenticated user. Signs a JWT carrying the
// identity claims plus authMethod + a fresh session id (jti), and returns both
// the raw token (the caller sets the cookie or returns it as a bearer token)
// and the decoded Session object. Does NOT touch cookies or the DB — issuing the
// cookie and recording the login stay in the controller so its bot-scoring /
// backoff / activity-log orchestration is unchanged.
function createSession(user, authMethod = 'local') {
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
const sessionId = crypto.randomUUID()
const raw = token.signToken(user, { authMethod: method, jti: sessionId, typ: SESSION_TYP })
const session = sessionFromDecoded(token.verifyToken(raw))
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
return { token: raw, session }
}
// Issue the short-lived "password verified, awaiting TOTP" challenge. This is
// deliberately NOT a session — validateSession rejects it — so a half-completed
// login can never be presented as a full one.
function createPartialSession(user) {
log.info('partial (TOTP) session issued', { userId: user.id, username: user.username })
return token.signTotpChallenge(user)
}
// Complete the TOTP step: verify the challenge token and return the decoded
// identity ({ id, stage }) so the caller can load the user and createSession().
// Returns null for an expired/invalid/non-challenge token.
function upgradeSessionAfterTotp(challengeToken) {
const decoded = token.verifyTotpChallenge(challengeToken)
if (!decoded) {
log.warn('TOTP challenge rejected (expired or invalid)')
return null
}
return decoded
}
// Validate the session on an incoming request WITHOUT hitting the DB — pure
// token verification + identity decode. Returns a Session object or null.
// Stage-tagged tokens (the TOTP challenge) are explicitly not sessions.
// DB re-validation of the user is a middleware concern (requireAuth), kept
// separate so a demoted/deleted user still loses access on the next request.
function validateSession(req, now = Date.now()) {
const raw = token.extractToken(req)
if (!raw) return null
return sessionFromDecoded(token.verifyToken(raw), now)
}
// Decode a raw token string into a Session object (or null). Used where the
// token is already in hand rather than on a request.
function decodeIdentity(rawToken, now = Date.now()) {
if (!rawToken) return null
return sessionFromDecoded(token.verifyToken(rawToken), now)
}
// ── Mobile (bearer) sessions ───────────────────────────────────────────────
// Native clients get a short-lived JWT access token (validated on every request
// exactly like a cookie session) plus a long-lived opaque refresh token. The
// refresh token is random and never a JWT: it is stored server-side by hash and
// is the only revocable half, which is what makes mobile logout meaningful.
//
// These functions are intentionally pure — they mint and hash but do NOT touch
// the database. The controller persists the returned refreshHash via the
// mobileSessions model, keeping this module DB-free and unit-testable.
const MOBILE_ACCESS_TTL = process.env.MOBILE_ACCESS_TTL || '15m'
const MOBILE_REFRESH_TTL_DAYS = Number(process.env.MOBILE_REFRESH_TTL_DAYS) || 30
// Hash a raw refresh token to the value stored in the DB. Exported so the
// controller and model agree on the exact representation.
function hashRefreshToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex')
}
// Mint a fresh access + refresh pair for a user. `now` is injectable for tests.
function mintMobileTokens(user, meta = {}, now = Date.now()) {
const sessionId = crypto.randomUUID()
const accessToken = token.signToken(
user,
{ authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
{ expiresIn: MOBILE_ACCESS_TTL },
)
// 256 bits of entropy, url-safe. Opaque — carries no claims.
const refreshToken = crypto.randomBytes(32).toString('base64url')
const refreshExpiresAt = new Date(now + MOBILE_REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000)
return {
accessToken,
refreshToken,
refreshHash: hashRefreshToken(refreshToken),
refreshExpiresAt,
expiresIn: MOBILE_ACCESS_TTL,
deviceHash: meta.deviceHash || null,
userAgent: meta.userAgent || null,
session: sessionFromDecoded(token.verifyToken(accessToken), now),
}
}
// Issue a mobile session at login.
function createMobileSession(user, meta = {}, now = Date.now()) {
const out = mintMobileTokens(user, meta, now)
log.info('mobile session created', { userId: user.id, username: user.username, sessionId: out.session.sessionId })
return out
}
// Rotate a mobile session on refresh — same shape as createMobileSession. The
// caller is responsible for having validated + revoked the presented refresh
// token before calling this (rotation), and for persisting the new refreshHash.
function refreshMobileSession(user, meta = {}, now = Date.now()) {
const out = mintMobileTokens(user, meta, now)
log.info('mobile session refreshed', { userId: user.id, sessionId: out.session.sessionId })
return out
}
// Validate a raw bearer access token → Session object or null. Rejects
// stage-tagged tokens (a TOTP challenge is not a bearer session).
function validateBearerToken(rawToken, now = Date.now()) {
if (!rawToken) return null
return sessionFromDecoded(token.verifyToken(rawToken), now)
}
// Optional per-session metadata derived from the request. Attached to the
// session object by middleware for logging/auditing; NOT baked into the token
// (keeps tokens small and avoids trusting client-supplied device data as a claim).
function sessionMeta(req) {
const ip = req.ip || null
const userAgent = (req.headers && req.headers['user-agent']) || null
const deviceHash = crypto
.createHash('sha256')
.update(`${userAgent || ''}|${ip || ''}`)
.digest('hex')
.slice(0, 16)
return { ip, userAgent, deviceHash }
}
// ── Trusted devices (MFA "Trust this device") ──────────────────────────────
// A trusted device lets a login SKIP the TOTP step (never the password). The
// opaque trust token lives client-side (rg_trust cookie on web, X-Trust-Token /
// EncryptedSharedPreferences on native); only its sha256 hash is stored, so — like
// the mobile refresh token — the server side is revocable and never holds the raw
// secret. These functions mint/hash/resolve; the controller sets the cookie and
// the trustedDevices model persists the row. sha256 (not bcrypt): the token is a
// 256-bit random value looked up BY its hash via a UNIQUE index.
const TRUSTED_DEVICE_TTL_DAYS = Number(process.env.TRUSTED_DEVICE_TTL_DAYS) || 30
// Hash a raw trust token to the value stored in the DB. Separate name from
// hashRefreshToken so intent is explicit at call sites, though the algorithm is
// the same deterministic sha256.
function hashTrustToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex')
}
// Mint a fresh opaque trust token + its hash + expiry. `meta` (from sessionMeta)
// supplies the best-effort device fingerprint stored for display. `now` injectable
// for tests. Does NOT touch cookies or the DB.
function mintTrustToken(meta = {}, now = Date.now()) {
const trustToken = crypto.randomBytes(32).toString('base64url') // 256 bits, opaque
const expiresAt = new Date(now + TRUSTED_DEVICE_TTL_DAYS * 24 * 60 * 60 * 1000)
return {
trustToken,
trustHash: hashTrustToken(trustToken),
deviceHash: meta.deviceHash || null,
userAgent: meta.userAgent || null,
expiresAt,
}
}
// Resolve the trust token on an incoming request to its still-valid DB row (or
// null). The caller MUST confirm row.user_id matches the user who just passed the
// password step before honoring it — a trust token is scoped to the account that
// created it. Never throws on a DB hiccup here; the caller falls back to TOTP.
async function resolveTrustedDevice(req) {
const raw = token.extractTrustToken(req)
if (!raw) return null
return trustedDevices.findValidByHash(hashTrustToken(raw))
}
// Stamp a trusted device as used (called when its trust was honored to skip TOTP).
async function honorTrustedDevice(id) {
if (!id) return false
await trustedDevices.touchLastUsed(id)
return true
}
// True if the user already holds the maximum number of trusted devices. Callers
// refuse a new trust (signaling the client to revoke one first) rather than
// pruning silently. See docs/website/TRUSTED_DEVICES_MFA.md §5.
async function trustDeviceCapReached(userId) {
return trustedDevices.isAtCap(userId)
}
// ── Revocation / invalidation ──────────────────────────────────────────────
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
// two server-side stores these functions write:
// • revoked_sessions — a per-session jti denylist (single logout)
// • users.tokens_valid_after — a per-user cutoff (log out everywhere)
// A jti + its expiry (from the decoded token) are needed to denylist one session;
// invalidating all of a user's sessions only needs their id.
// Revoke a single session by its jti. Needs the token's expiry so the denylist
// row can self-prune once the JWT would fail verification anyway. Idempotent.
async function revokeSession(sessionId, { userId = null, expiresAt } = {}) {
if (!sessionId) {
log.warn('revokeSession called without a sessionId (jti) — nothing to revoke')
return false
}
// Fall back to the max JWT lifetime if the caller didn't pass the token's exp,
// so the denylist row still outlives any token carrying this jti.
const exp = expiresAt || Date.now() + token.cookieMaxAge()
await revokedSessions.revoke({ jti: sessionId, userId, expiresAt: exp })
log.info('session revoked', { sessionId, userId })
return true
}
// Alias kept for callers that speak of "invalidating" one session.
async function invalidateSession(sessionId, opts) {
return revokeSession(sessionId, opts)
}
// Has this session (jti) been individually revoked? Used by requireAuth on every
// authenticated request. Broad "valid after" cutoffs are checked separately by
// the middleware against the fresh user row it already loads.
async function isSessionRevoked(sessionId) {
if (!sessionId) return false
return revokedSessions.isRevoked(sessionId)
}
// Invalidate every session a user holds (password change / log out everywhere)
// by advancing their tokens_valid_after cutoff. Covers cookie sessions issued
// before now regardless of jti.
async function invalidateAllUserSessions(userId) {
if (!userId) {
log.warn('invalidateAllUserSessions called without a userId')
return false
}
await users.invalidateSessions(userId)
log.info('all user sessions invalidated', { userId })
return true
}
module.exports = {
AUTH_METHODS,
createSession,
createPartialSession,
upgradeSessionAfterTotp,
validateSession,
decodeIdentity,
sessionMeta,
revokeSession,
invalidateSession,
isSessionRevoked,
invalidateAllUserSessions,
// Mobile bearer sessions.
createMobileSession,
refreshMobileSession,
validateBearerToken,
hashRefreshToken,
// Trusted devices (MFA "Trust this device").
hashTrustToken,
mintTrustToken,
resolveTrustedDevice,
honorTrustedDevice,
trustDeviceCapReached,
}