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