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>
125 lines
5.1 KiB
JavaScript
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)
|
|
})
|