Add session abstraction, mobile bearer auth, and pluggable SSO
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).
Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
(create/validate/partial-TOTP/revoke), session.middleware.js
(attachSession/requireAuth/requireRole). utils/auth.js is now a thin
compat facade so existing imports are unchanged.
Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
long-lived refresh token, stored hashed and rotated on use, in a new
mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
TOTP. token.signToken gains a backward-compatible expiresIn option.
Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
identities are never auto-provisioned. Client secrets encrypted at rest
(AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
account linking (/admin/account/identities). New auth_providers +
user_identities tables.
Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
graceful with zero providers). New Authentication admin view
(Local/Google/Discord/Custom). Account page linked-accounts section.
Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
218
server/src/auth/session.service.js
Normal file
218
server/src/auth/session.service.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// ── 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)
|
||||
// lastSeenAt, // ms epoch this session was last validated
|
||||
// }
|
||||
//
|
||||
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
|
||||
// server-side session store yet — these are documented hook points for a future
|
||||
// store (e.g. a denylist of jti, or mobile refresh-token records).
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const token = require('./token')
|
||||
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']
|
||||
|
||||
// Build a Session object from a decoded JWT payload. Returns null for anything
|
||||
// that is not a full session (e.g. a stage-tagged TOTP challenge token).
|
||||
function sessionFromDecoded(decoded, now = Date.now()) {
|
||||
if (!decoded || decoded.stage) 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,
|
||||
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 })
|
||||
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 },
|
||||
{ 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 (stubs) ──────────────────────────────────────
|
||||
// JWTs are stateless: there is no store to revoke against yet. These are the
|
||||
// hook points a future session store (jti denylist, mobile refresh records)
|
||||
// will implement. They log and report success so callers can wire them in now.
|
||||
|
||||
function revokeSession(sessionId) {
|
||||
log.info('revokeSession (stub — no session store yet)', { sessionId })
|
||||
return true
|
||||
}
|
||||
|
||||
function invalidateSession(sessionId) {
|
||||
log.info('invalidateSession (stub — no session store yet)', { sessionId })
|
||||
return true
|
||||
}
|
||||
|
||||
function invalidateAllUserSessions(userId) {
|
||||
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUTH_METHODS,
|
||||
createSession,
|
||||
createPartialSession,
|
||||
upgradeSessionAfterTotp,
|
||||
validateSession,
|
||||
decodeIdentity,
|
||||
sessionMeta,
|
||||
revokeSession,
|
||||
invalidateSession,
|
||||
invalidateAllUserSessions,
|
||||
// Mobile bearer sessions.
|
||||
createMobileSession,
|
||||
refreshMobileSession,
|
||||
validateBearerToken,
|
||||
hashRefreshToken,
|
||||
}
|
||||
Reference in New Issue
Block a user