Files
website/server/test/session.test.js
Claude 31b31c3a17 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>
2026-07-03 10:31:29 -05:00

125 lines
5.1 KiB
JavaScript

// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and
// db.js (the users model, pulled in via the utils/auth facade, builds the pool
// at load). Pointing the DB at a closed port stops idle connections from keeping
// this process alive — none of these tests touch the database.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const sessionService = require('../src/auth/session.service')
const authFacade = require('../src/utils/auth')
const db = require('../src/utils/db')
after(() => db.close())
const USER = { id: 7, username: 'alice', role: 'admin' }
// Build a request double carrying a token, either as a cookie or a Bearer header.
function reqWithCookie(token) {
return { cookies: { [authFacade.COOKIE_NAME]: token }, headers: {} }
}
function reqWithBearer(token) {
return { cookies: {}, headers: { authorization: `Bearer ${token}` } }
}
test('createSession → validateSession round-trips a Session object', () => {
const { token, session } = sessionService.createSession(USER, 'local')
assert.equal(typeof token, 'string')
// The returned session object carries the canonical shape.
assert.equal(session.userId, USER.id)
assert.equal(session.username, USER.username)
assert.equal(session.role, USER.role)
assert.equal(session.authMethod, 'local')
assert.ok(session.sessionId, 'sessionId (jti) is present')
assert.equal(typeof session.createdAt, 'number')
// Validating the same token off a request yields the same identity.
const validated = sessionService.validateSession(reqWithCookie(token))
assert.ok(validated)
assert.equal(validated.userId, USER.id)
assert.equal(validated.username, USER.username)
assert.equal(validated.role, USER.role)
assert.equal(validated.authMethod, 'local')
assert.equal(validated.sessionId, session.sessionId)
})
test('validateSession accepts a Bearer token as well as a cookie', () => {
const { token } = sessionService.createSession(USER, 'mobile')
const validated = sessionService.validateSession(reqWithBearer(token))
assert.ok(validated)
assert.equal(validated.userId, USER.id)
assert.equal(validated.authMethod, 'mobile')
})
test('authMethod defaults to local when an unknown method is passed', () => {
const { session } = sessionService.createSession(USER, 'bogus')
assert.equal(session.authMethod, 'local')
})
test('a partial (TOTP challenge) token is NOT a valid session', () => {
const challenge = sessionService.createPartialSession(USER)
assert.equal(typeof challenge, 'string')
// Stage-tagged tokens must never validate as a full session.
assert.equal(sessionService.validateSession(reqWithCookie(challenge)), null)
assert.equal(sessionService.decodeIdentity(challenge), null)
})
test('upgradeSessionAfterTotp accepts a challenge and rejects a session token', () => {
const challenge = sessionService.createPartialSession(USER)
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
assert.ok(decoded)
assert.equal(decoded.id, USER.id)
assert.equal(decoded.stage, 'totp')
// A normal session token is not a TOTP challenge — must be rejected here.
const { token } = sessionService.createSession(USER, 'local')
assert.equal(sessionService.upgradeSessionAfterTotp(token), null)
})
test('validateSession / decodeIdentity return null for missing or garbage input', () => {
assert.equal(sessionService.validateSession({ cookies: {}, headers: {} }), null)
assert.equal(sessionService.decodeIdentity(null), null)
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
})
test('revoke / invalidate stubs report success without throwing', () => {
assert.equal(sessionService.revokeSession('sid-1'), true)
assert.equal(sessionService.invalidateSession('sid-1'), true)
assert.equal(sessionService.invalidateAllUserSessions(USER.id), true)
})
test('sessionMeta derives ip / userAgent / deviceHash from the request', () => {
const meta = sessionService.sessionMeta({ ip: '203.0.113.5', headers: { 'user-agent': 'jest' } })
assert.equal(meta.ip, '203.0.113.5')
assert.equal(meta.userAgent, 'jest')
assert.equal(typeof meta.deviceHash, 'string')
assert.ok(meta.deviceHash.length > 0)
})
test('backward-compat: utils/auth facade still exports the original API', () => {
for (const name of [
'isLoggedIn',
'requireRole',
'signToken',
'verifyToken',
'signTotpChallenge',
'verifyTotpChallenge',
'setAuthCookie',
'clearAuthCookie',
'getUserFromRequest',
]) {
assert.equal(typeof authFacade[name], 'function', `${name} is exported as a function`)
}
assert.equal(typeof authFacade.COOKIE_NAME, 'string')
// getUserFromRequest still returns the historical { id, username, role } shape.
const { token } = sessionService.createSession(USER, 'local')
const decoded = authFacade.getUserFromRequest(reqWithCookie(token))
assert.deepEqual(decoded, { id: USER.id, username: USER.username, role: USER.role })
assert.equal(authFacade.getUserFromRequest({ cookies: {}, headers: {} }), null)
})