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>
This commit is contained in:
81
server/test/mobileSession.test.js
Normal file
81
server/test/mobileSession.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and
|
||||
// db.js (pulled in transitively; the pool builds at load). Closed DB port keeps
|
||||
// idle connections from holding the process open — these tests are DB-free and
|
||||
// only exercise the pure token/hash logic of the mobile session service.
|
||||
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 jwt = require('jsonwebtoken')
|
||||
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const tokenLib = require('../src/auth/token')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const USER = { id: 42, username: 'mobileuser', role: 'admin' }
|
||||
|
||||
test('createMobileSession mints a bearer-validatable access token + opaque refresh token', () => {
|
||||
const out = sessionService.createMobileSession(USER, { deviceHash: 'abc', userAgent: 'Android' })
|
||||
|
||||
// Access token validates as a mobile session.
|
||||
const session = sessionService.validateBearerToken(out.accessToken)
|
||||
assert.ok(session)
|
||||
assert.equal(session.userId, USER.id)
|
||||
assert.equal(session.authMethod, 'mobile')
|
||||
assert.ok(session.sessionId)
|
||||
|
||||
// Refresh token is opaque (not a JWT) and its stored form is the sha256 hash.
|
||||
assert.equal(typeof out.refreshToken, 'string')
|
||||
assert.ok(out.refreshToken.length >= 40)
|
||||
assert.equal(out.refreshHash, sessionService.hashRefreshToken(out.refreshToken))
|
||||
assert.equal(sessionService.validateBearerToken(out.refreshToken), null, 'refresh token is not a bearer session')
|
||||
|
||||
// Metadata + a future expiry are carried through for the controller to persist.
|
||||
assert.equal(out.deviceHash, 'abc')
|
||||
assert.equal(out.userAgent, 'Android')
|
||||
assert.ok(out.refreshExpiresAt instanceof Date)
|
||||
assert.ok(out.refreshExpiresAt.getTime() > Date.now())
|
||||
})
|
||||
|
||||
test('access token is short-lived (mobile TTL, not the 1d web default)', () => {
|
||||
const { accessToken } = sessionService.createMobileSession(USER)
|
||||
const decoded = jwt.decode(accessToken)
|
||||
const lifetime = decoded.exp - decoded.iat
|
||||
// Default MOBILE_ACCESS_TTL is 15m — comfortably under the 1d web session.
|
||||
assert.ok(lifetime <= 15 * 60, `access token lifetime ${lifetime}s should be <= 15m`)
|
||||
})
|
||||
|
||||
test('refreshMobileSession issues a distinct new pair (rotation)', () => {
|
||||
const a = sessionService.createMobileSession(USER)
|
||||
const b = sessionService.refreshMobileSession(USER)
|
||||
assert.notEqual(a.refreshToken, b.refreshToken)
|
||||
assert.notEqual(a.refreshHash, b.refreshHash)
|
||||
})
|
||||
|
||||
test('hashRefreshToken is stable and deterministic', () => {
|
||||
assert.equal(sessionService.hashRefreshToken('token-xyz'), sessionService.hashRefreshToken('token-xyz'))
|
||||
assert.notEqual(sessionService.hashRefreshToken('a'), sessionService.hashRefreshToken('b'))
|
||||
// sha256 hex is 64 chars.
|
||||
assert.equal(sessionService.hashRefreshToken('anything').length, 64)
|
||||
})
|
||||
|
||||
test('validateBearerToken rejects a TOTP challenge and garbage', () => {
|
||||
const challenge = sessionService.createPartialSession(USER)
|
||||
assert.equal(sessionService.validateBearerToken(challenge), null)
|
||||
assert.equal(sessionService.validateBearerToken('not-a-jwt'), null)
|
||||
assert.equal(sessionService.validateBearerToken(null), null)
|
||||
})
|
||||
|
||||
test('token.signToken honors an expiresIn override, else uses the default', () => {
|
||||
const short = tokenLib.signToken(USER, {}, { expiresIn: '1s' })
|
||||
const shortDecoded = jwt.decode(short)
|
||||
assert.equal(shortDecoded.exp - shortDecoded.iat, 1)
|
||||
|
||||
// No option → historical default (JWT_EXPIRES_IN, 1d) unchanged.
|
||||
const dflt = jwt.decode(tokenLib.signToken(USER))
|
||||
assert.equal(dflt.exp - dflt.iat, 24 * 60 * 60)
|
||||
})
|
||||
92
server/test/providers.test.js
Normal file
92
server/test/providers.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
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, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const GoogleProvider = require('../src/auth/providers/google.provider')
|
||||
const DiscordProvider = require('../src/auth/providers/discord.provider')
|
||||
const GenericOidcProvider = require('../src/auth/providers/genericOidc.provider')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const realFetch = global.fetch
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch
|
||||
})
|
||||
|
||||
// Install a fetch stub that answers by URL substring.
|
||||
function mockFetch(routes) {
|
||||
global.fetch = async (url) => {
|
||||
for (const [needle, payload] of Object.entries(routes)) {
|
||||
if (String(url).includes(needle)) {
|
||||
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
|
||||
}
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => 'not found' }
|
||||
}
|
||||
}
|
||||
|
||||
test('Google getAuthorizationUrl includes client_id, redirect_uri, scope, state, PKCE', () => {
|
||||
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
|
||||
const url = p.getAuthorizationUrl('the-state', { redirectUri: 'https://app/cb', codeChallenge: 'CHAL' })
|
||||
assert.ok(url.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
|
||||
const q = new URL(url).searchParams
|
||||
assert.equal(q.get('client_id'), 'gid')
|
||||
assert.equal(q.get('redirect_uri'), 'https://app/cb')
|
||||
assert.equal(q.get('response_type'), 'code')
|
||||
assert.equal(q.get('scope'), 'openid email profile')
|
||||
assert.equal(q.get('state'), 'the-state')
|
||||
assert.equal(q.get('code_challenge'), 'CHAL')
|
||||
assert.equal(q.get('code_challenge_method'), 'S256')
|
||||
})
|
||||
|
||||
test('Google handleCallback exchanges code and normalizes the profile', async () => {
|
||||
mockFetch({
|
||||
'oauth2.googleapis.com/token': { access_token: 'AT' },
|
||||
'openidconnect.googleapis.com/v1/userinfo': { sub: '11550', email: 'alice@example.com', name: 'Alice' },
|
||||
})
|
||||
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
|
||||
assert.deepEqual(profile, { subject: '11550', email: 'alice@example.com', name: 'Alice' })
|
||||
})
|
||||
|
||||
test('Discord authorize URL + profile mapping (global_name → name, id → subject)', async () => {
|
||||
const p = new DiscordProvider({ id: 'discord', clientId: 'did', clientSecret: 'dsecret' })
|
||||
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb' })
|
||||
assert.ok(url.startsWith('https://discord.com/oauth2/authorize?'))
|
||||
assert.equal(new URL(url).searchParams.get('scope'), 'identify email')
|
||||
|
||||
mockFetch({
|
||||
'discord.com/api/oauth2/token': { access_token: 'AT' },
|
||||
'discord.com/api/users/@me': { id: '99', username: 'bob', global_name: 'Bob', email: 'bob@x.io' },
|
||||
})
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb' })
|
||||
assert.deepEqual(profile, { subject: '99', email: 'bob@x.io', name: 'Bob' })
|
||||
})
|
||||
|
||||
test('Generic OIDC provider uses configured endpoints and OIDC profile fields', async () => {
|
||||
const p = new GenericOidcProvider({
|
||||
id: 'authentik', kind: 'oidc', clientId: 'cid', clientSecret: 'csec',
|
||||
authorizeUrl: 'https://idp.example/authorize', tokenUrl: 'https://idp.example/token',
|
||||
userinfoUrl: 'https://idp.example/userinfo', scopes: 'openid email',
|
||||
})
|
||||
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb', codeChallenge: 'CH' })
|
||||
assert.ok(url.startsWith('https://idp.example/authorize?'))
|
||||
assert.equal(new URL(url).searchParams.get('scope'), 'openid email')
|
||||
|
||||
mockFetch({
|
||||
'idp.example/token': { access_token: 'AT' },
|
||||
'idp.example/userinfo': { sub: 'abc', email: 'c@d.e', preferred_username: 'carol' },
|
||||
})
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
|
||||
assert.deepEqual(profile, { subject: 'abc', email: 'c@d.e', name: 'carol' })
|
||||
})
|
||||
|
||||
test('handleCallback throws when the token exchange fails', async () => {
|
||||
mockFetch({}) // everything 404s
|
||||
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
|
||||
await assert.rejects(() => p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' }))
|
||||
})
|
||||
53
server/test/registry.test.js
Normal file
53
server/test/registry.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registry = require('../src/auth/providers/registry')
|
||||
const authProvidersModel = require('../src/model/authProviders/authProviders.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
test('validateConfig: built-in needs client_id + secret', () => {
|
||||
assert.equal(registry.validateConfig({ kind: 'google', client_id: 'x', client_secret_enc: 'e' }).valid, true)
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'google', client_id: 'x' }).missing, ['client_secret'])
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'google' }).missing, ['client_id', 'client_secret'])
|
||||
})
|
||||
|
||||
test('validateConfig: custom OIDC also needs the endpoint URLs', () => {
|
||||
const complete = {
|
||||
kind: 'oidc', client_id: 'x', client_secret_enc: 'e',
|
||||
authorize_url: 'a', token_url: 't', userinfo_url: 'u',
|
||||
}
|
||||
assert.equal(registry.validateConfig(complete).valid, true)
|
||||
const noUrls = { kind: 'oidc', client_id: 'x', client_secret_enc: 'e' }
|
||||
assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url'])
|
||||
})
|
||||
|
||||
test('listConfigured always includes both built-ins with health', async () => {
|
||||
authProvidersModel.list = async () => [] // no rows yet
|
||||
const out = await registry.listConfigured()
|
||||
const ids = out.map((p) => p.id)
|
||||
assert.deepEqual(ids, ['google', 'discord'])
|
||||
assert.equal(out[0].builtin, true)
|
||||
assert.equal(out[0].enabled, 0)
|
||||
assert.equal(out[0].health.valid, false) // unconfigured
|
||||
})
|
||||
|
||||
test('listEnabledValid returns only enabled+valid, shaped and sorted by priority', async () => {
|
||||
authProvidersModel.list = async () => [
|
||||
{ id: 'discord', kind: 'discord', name: 'Discord', enabled: 1, client_id: 'd', client_secret_enc: 'e', priority: 2 },
|
||||
{ id: 'google', kind: 'google', name: 'Google', enabled: 1, client_id: 'g', client_secret_enc: 'e', priority: 1 },
|
||||
{ id: 'brokenidp', kind: 'oidc', name: 'Broken', enabled: 1, client_id: 'x', client_secret_enc: 'e', priority: 0 }, // missing URLs → hidden
|
||||
{ id: 'authentik', kind: 'oidc', name: 'Authentik', enabled: 0, client_id: 'x', client_secret_enc: 'e', authorize_url: 'a', token_url: 't', userinfo_url: 'u', priority: 3 }, // disabled → hidden
|
||||
]
|
||||
const out = await registry.listEnabledValid()
|
||||
assert.deepEqual(out.map((p) => p.id), ['google', 'discord']) // sorted by priority, broken/disabled excluded
|
||||
assert.deepEqual(out[0], {
|
||||
id: 'google', name: 'Google', icon: 'google', loginUrl: '/api/v1/auth/sso/google/start', priority: 1,
|
||||
})
|
||||
})
|
||||
37
server/test/secretBox.test.js
Normal file
37
server/test/secretBox.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const secretBox = require('../src/utils/secretBox')
|
||||
|
||||
test('encrypt → decrypt round-trips a secret', () => {
|
||||
const plain = 'super-secret-oauth-client-secret'
|
||||
const enc = secretBox.encrypt(plain)
|
||||
assert.notEqual(enc, plain)
|
||||
assert.match(enc, /^[^:]+:[^:]+:[^:]+$/) // iv:tag:ct
|
||||
assert.equal(secretBox.decrypt(enc), plain)
|
||||
})
|
||||
|
||||
test('ciphertext differs each call (random IV) but both decrypt', () => {
|
||||
const a = secretBox.encrypt('x')
|
||||
const b = secretBox.encrypt('x')
|
||||
assert.notEqual(a, b)
|
||||
assert.equal(secretBox.decrypt(a), 'x')
|
||||
assert.equal(secretBox.decrypt(b), 'x')
|
||||
})
|
||||
|
||||
test('null/blank round-trips to null', () => {
|
||||
assert.equal(secretBox.encrypt(''), null)
|
||||
assert.equal(secretBox.encrypt(null), null)
|
||||
assert.equal(secretBox.decrypt(null), null)
|
||||
assert.equal(secretBox.decrypt(''), null)
|
||||
})
|
||||
|
||||
test('tampered ciphertext fails authentication', () => {
|
||||
const enc = secretBox.encrypt('secret')
|
||||
const [iv, tag, ct] = enc.split(':')
|
||||
const tampered = `${iv}:${tag}:${Buffer.from('garbage').toString('base64')}`
|
||||
assert.throws(() => secretBox.decrypt(tampered))
|
||||
assert.throws(() => secretBox.decrypt('only:two')) // malformed
|
||||
})
|
||||
124
server/test/session.test.js
Normal file
124
server/test/session.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// 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)
|
||||
})
|
||||
116
server/test/ssoCallback.test.js
Normal file
116
server/test/ssoCallback.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
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)
|
||||
})
|
||||
38
server/test/ssoState.test.js
Normal file
38
server/test/ssoState.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const ssoState = require('../src/auth/ssoState')
|
||||
|
||||
test('createTx → verifyTx round-trips the flow payload', () => {
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
|
||||
assert.ok(tx.nonce && tx.verifier && tx.codeChallenge && tx.txToken)
|
||||
|
||||
const payload = ssoState.verifyTx(tx.txToken, tx.nonce)
|
||||
assert.ok(payload)
|
||||
assert.equal(payload.provider, 'google')
|
||||
assert.equal(payload.mode, 'login')
|
||||
assert.equal(payload.returnTo, '/admin/posts')
|
||||
assert.equal(payload.verifier, tx.verifier)
|
||||
})
|
||||
|
||||
test('codeChallenge is the S256 hash of the verifier', () => {
|
||||
const tx = ssoState.createTx({ provider: 'discord', mode: 'login' })
|
||||
const expected = crypto.createHash('sha256').update(tx.verifier).digest('base64url')
|
||||
assert.equal(tx.codeChallenge, expected)
|
||||
})
|
||||
|
||||
test('verifyTx rejects a mismatched / tampered nonce', () => {
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
|
||||
assert.equal(ssoState.verifyTx(tx.txToken, 'wrong-nonce'), null)
|
||||
assert.equal(ssoState.verifyTx(tx.txToken, null), null)
|
||||
assert.equal(ssoState.verifyTx(null, tx.nonce), null)
|
||||
})
|
||||
|
||||
test('verifyTx rejects a non-tx token', () => {
|
||||
const token = require('../src/auth/token')
|
||||
const notTx = token.signToken({ id: 1, username: 'a', role: 'admin' })
|
||||
assert.equal(ssoState.verifyTx(notTx, 'anything'), null)
|
||||
})
|
||||
Reference in New Issue
Block a user