sessionFromDecoded validated sessions with a blocklist — it rejected a
token only when `decoded.stage` was present (the TOTP challenge). Because
every JWT is signed with the same JWT_SECRET and distinguished only by
claims, the SSO transaction cookie (sso_tx, which carries kind:'sso_tx'
and id:'sso' but no stage) passed validation and was accepted as a bogus
{ userId:'sso' } session.
requireAuth's DB re-load blocked protected admin routes, but non-DB
identity checks were fooled — notably siteMode's maintenance-preview
bypass, which trusts any truthy getUserFromRequest. An attacker could
start an SSO flow to obtain an sso_tx cookie and replay it as the auth
cookie / Bearer token to bypass the maintenance gate. The broader risk
was latent: any future code path trusting attachSession/getUserFromRequest
without a DB round-trip inherited an auth bypass.
Make session validation positively typed: real sessions are now stamped
with typ:'session' (createSession + mintMobileTokens), and
sessionFromDecoded accepts a token only when that marker is present. As
belt-and-suspenders it also rejects any token carrying a non-session
marker (stage || kind). Flow/challenge tokens are never stamped, so they
can no longer be mistaken for sessions.
Note: existing web cookie sessions predating this change lack the typ
claim and will be rejected once — users re-login. Mobile clients recover
automatically on next refresh.
Adds regression tests: the sso_tx flow token and a bare identity token
are both rejected by validateSession / decodeIdentity / getUserFromRequest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
268 lines
12 KiB
JavaScript
268 lines
12 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 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 }
|
|
}
|
|
|
|
// ── 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,
|
|
}
|