Files
website/server/src/auth/session.service.js
Claude 5f62eccdd8 Fix SSO flow-token / session type confusion (#32)
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>
2026-07-04 21:54:58 -05:00

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