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>
93 lines
4.3 KiB
JavaScript
93 lines
4.3 KiB
JavaScript
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' }))
|
|
})
|