// ── 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, }