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>
This commit is contained in:
@@ -10,6 +10,7 @@ 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')
|
||||
@@ -73,6 +74,28 @@ test('a partial (TOTP challenge) token is NOT a valid session', () => {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user