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

117 lines
5.1 KiB
JavaScript

process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
const ssoState = require('../src/auth/ssoState')
const token = require('../src/auth/token')
// Modules whose methods we stub (exports are plain objects → mutable in-process).
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const registry = require('../src/auth/providers/registry')
const db = require('../src/utils/db')
after(() => db.close())
const GOOGLE_ROW = { id: 'google', kind: 'google', name: 'Google', enabled: 1, client_id: 'cid', client_secret_enc: 'enc' }
const PROFILE = { subject: 'sub-1', email: 'alice@example.com', name: 'Alice' }
let logged
beforeEach(() => {
logged = []
activity.log = async (evt) => { logged.push(evt) }
authProviders.getWithSecret = async () => ({ ...GOOGLE_ROW })
// Bypass real OAuth network calls: the provider just yields a fixed profile.
registry.instantiate = () => ({ handleCallback: async () => ({ ...PROFILE }) })
userIdentities.findByProviderSubject = async () => null
userIdentities.link = async () => 1
users.getById = async (id) => ({ id, username: 'alice', role: 'admin' })
users.recordLogin = async () => {} // avoid the real DB on the success path
})
function mockRes() {
return {
statusCode: 200, redirectedTo: null, cookies: {}, cleared: [],
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
redirect(u) { this.redirectedTo = u; return this },
cookie(n, v) { this.cookies[n] = v; return this },
clearCookie(n) { this.cleared.push(n); return this },
}
}
function makeReq(tx, { state, code = 'auth-code' } = {}) {
return {
params: { provider: 'google' },
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
query: { state: state ?? tx.nonce, code },
ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
}
}
test('linked identity → session cookie set, redirect to /admin, login logged', async () => {
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie was set')
assert.equal(res.redirectedTo, '/admin')
assert.ok(res.cleared.includes(ssoState.TX_COOKIE), 'tx cookie cleared')
assert.equal(logged.at(-1).action, 'auth.sso.login')
})
test('linked identity honors a safe returnTo', async () => {
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.redirectedTo, '/admin/posts')
})
test('UNLINKED identity → no session, redirect to not_linked (link-only policy)', async () => {
userIdentities.findByProviderSubject = async () => null
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no session cookie')
assert.equal(res.redirectedTo, '/admin/login?sso_error=not_linked')
assert.equal(logged.length, 0)
})
test('link mode → identity linked to the acting user, redirect to account', async () => {
let linkArgs = null
userIdentities.link = async (args) => { linkArgs = args; return 1 }
const tx = ssoState.createTx({ provider: 'google', mode: 'link', linkUserId: 5 })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com' })
assert.equal(res.redirectedTo, '/admin/account?linked=google')
assert.equal(logged.at(-1).action, 'auth.sso.link')
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
})
test('link mode refuses an identity already owned by another user', async () => {
userIdentities.findByProviderSubject = async () => ({ user_id: 999 })
const tx = ssoState.createTx({ provider: 'google', mode: 'link', linkUserId: 5 })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.redirectedTo, '/admin/account?link_error=in_use')
})
test('bad state (CSRF) → rejected before any provider work', async () => {
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx, { state: 'tampered-nonce' }), res)
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
})