Files
website/server/test/session.test.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

205 lines
8.8 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 ssoState = require('../src/auth/ssoState')
const authFacade = require('../src/utils/auth')
const revokedSessions = require('../src/model/revokedSessions/revokedSessions.model')
const usersModel = require('../src/model/users/users.model')
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')
// exp is carried so logout can set a self-pruning denylist row expiry.
assert.equal(typeof session.expiresAt, 'number')
assert.ok(session.expiresAt > session.createdAt, 'expiresAt is after createdAt')
// 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('an SSO transaction (sso_tx) flow token is NOT a valid session (issue #32)', () => {
// The sso_tx cookie is a JWT signed with the same secret as sessions, carrying
// kind:'sso_tx' and id:'sso' but no `stage`. Before the fix it passed the
// blocklist check and validated as a bogus { userId:'sso' } session, which fooled
// non-DB identity checks (e.g. siteMode's maintenance-preview bypass).
const { txToken } = ssoState.createTx({ provider: 'google', mode: 'login' })
assert.equal(sessionService.validateSession(reqWithCookie(txToken)), null)
assert.equal(sessionService.validateSession(reqWithBearer(txToken)), null)
assert.equal(sessionService.decodeIdentity(txToken), null)
assert.equal(sessionService.validateBearerToken(txToken), null)
// And the historical facade used by siteMode must report no user.
assert.equal(authFacade.getUserFromRequest(reqWithCookie(txToken)), null)
})
test('a bare identity token with no session marker is NOT a valid session', () => {
// A JWT carrying only { id, username, role } (e.g. a legacy token, or one minted
// for some other purpose) must not validate: sessions are positively typed.
const bare = authFacade.signToken(USER)
assert.equal(sessionService.validateSession(reqWithCookie(bare)), null)
assert.equal(sessionService.decodeIdentity(bare), 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('revokeSession denylists the jti with the token expiry', async () => {
// Stub the store (the DB is intentionally unreachable in these tests) and
// capture what the seam persists. sessionService holds the same module object,
// so overwriting the method here is what it calls.
const calls = []
const orig = revokedSessions.revoke
revokedSessions.revoke = async (args) => { calls.push(args); return 1 }
try {
const exp = Date.now() + 60_000
const ok = await sessionService.revokeSession('sid-1', { userId: USER.id, expiresAt: exp })
assert.equal(ok, true)
assert.equal(calls.length, 1)
assert.equal(calls[0].jti, 'sid-1')
assert.equal(calls[0].userId, USER.id)
assert.equal(calls[0].expiresAt, exp)
} finally {
revokedSessions.revoke = orig
}
})
test('revokeSession is a no-op (returns false) without a sessionId', async () => {
let called = false
const orig = revokedSessions.revoke
revokedSessions.revoke = async () => { called = true; return 1 }
try {
assert.equal(await sessionService.revokeSession(undefined), false)
assert.equal(called, false, 'nothing is persisted when there is no jti')
} finally {
revokedSessions.revoke = orig
}
})
test('isSessionRevoked delegates to the denylist (and short-circuits on null)', async () => {
const orig = revokedSessions.isRevoked
revokedSessions.isRevoked = async (jti) => jti === 'revoked-sid'
try {
assert.equal(await sessionService.isSessionRevoked('revoked-sid'), true)
assert.equal(await sessionService.isSessionRevoked('fresh-sid'), false)
assert.equal(await sessionService.isSessionRevoked(null), false)
} finally {
revokedSessions.isRevoked = orig
}
})
test('invalidateAllUserSessions bumps the user cutoff (and guards a missing id)', async () => {
const ids = []
const orig = usersModel.invalidateSessions
usersModel.invalidateSessions = async (id) => { ids.push(id); return undefined }
try {
assert.equal(await sessionService.invalidateAllUserSessions(USER.id), true)
assert.deepEqual(ids, [USER.id])
assert.equal(await sessionService.invalidateAllUserSessions(undefined), false)
assert.deepEqual(ids, [USER.id], 'no bump when userId is missing')
} finally {
usersModel.invalidateSessions = orig
}
})
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)
})