import { describe, expect, it } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { jwtVerify, createLocalJWKSet } from 'jose' import { buildApp } from '../src/app.js' import { loadConfig } from '../src/config.js' import { resetOidcKeyCache } from '../src/lib/oidc/keys.js' async function buildTestApp() { resetOidcKeyCache() const config = loadConfig({ ...process.env, JWT_SECRET: 'test-secret-at-least-8', ADMIN_EMAIL: 'admin@test.local', ADMIN_PASSWORD: 'adminpass', DATABASE_URL: 'sqlite::memory:', ISSUER: 'https://auth.test.local', OIDC_ISSUER: 'https://auth.test.local', NODE_ENV: 'test', }) return buildApp({ config, databaseUrl: 'sqlite::memory:' }) } describe('OIDC IdP', () => { it('serves discovery and JWKS', async () => { const app = await buildTestApp() const discovery = await app.inject({ method: 'GET', url: '/.well-known/openid-configuration', }) expect(discovery.statusCode).toBe(200) const meta = discovery.json() as { issuer: string authorization_endpoint: string jwks_uri: string } expect(meta.issuer).toBe('https://auth.test.local') expect(meta.authorization_endpoint).toContain('/oauth/authorize') expect(meta.jwks_uri).toContain('/.well-known/jwks.json') const jwks = await app.inject({ method: 'GET', url: '/.well-known/jwks.json', }) expect(jwks.statusCode).toBe(200) const keys = jwks.json() as { keys: { kid: string; kty: string }[] } expect(keys.keys.length).toBeGreaterThan(0) expect(keys.keys[0]?.kty).toBe('RSA') await app.close() }) it('records auth.sso_handoff with target_app dns on authorize', async () => { const app = await buildTestApp() const login = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'admin@test.local', password: 'adminpass' }, }) expect(login.statusCode).toBe(200) const token = (login.json() as { access_token: string }).access_token const refresh = login.cookies.find((c) => c.name === 'refresh_token') expect(refresh?.value).toBeTruthy() const created = await app.inject({ method: 'POST', url: '/api/v1/admin/oidc/clients', headers: { authorization: `Bearer ${token}` }, payload: { name: 'Technitium', redirect_uris: ['https://dns.test.local/sso/callback'], scopes: ['openid', 'profile', 'email', 'groups'], enabled: true, }, }) const client = created.json() as { client_id: string } const authorize = await app.inject({ method: 'GET', url: '/oauth/authorize', cookies: { refresh_token: refresh!.value }, query: { client_id: client.client_id, redirect_uri: 'https://dns.test.local/sso/callback', response_type: 'code', scope: 'openid profile email groups', state: 'xyz', }, }) expect(authorize.statusCode).toBe(302) const audit = await app.inject({ method: 'GET', url: '/api/v1/admin/audit?kind=logins&limit=50', headers: { authorization: `Bearer ${token}` }, }) expect(audit.statusCode).toBe(200) const entries = audit.json() as { action: string details: Record | null }[] const handoff = entries.find( (e) => e.action === 'auth.sso_handoff' && e.details?.target_app === 'dns' && e.details?.auth_mode === 'oidc', ) expect(handoff).toBeTruthy() await app.close() }) it('reloads RS256 key from SQLite after restart (extractable import)', async () => { const dir = mkdtempSync(join(tmpdir(), 'oidc-key-')) const dbPath = `sqlite:${join(dir, 'app.db')}` try { resetOidcKeyCache() const config = loadConfig({ ...process.env, JWT_SECRET: 'test-secret-at-least-8', ADMIN_EMAIL: 'admin@test.local', ADMIN_PASSWORD: 'adminpass', DATABASE_URL: dbPath, ISSUER: 'https://auth.test.local', OIDC_ISSUER: 'https://auth.test.local', NODE_ENV: 'test', }) const first = await buildApp({ config, databaseUrl: dbPath }) const jwks1 = await first.inject({ method: 'GET', url: '/.well-known/jwks.json', }) expect(jwks1.statusCode).toBe(200) const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid expect(kid).toBeTruthy() await first.close() resetOidcKeyCache() const second = await buildApp({ config, databaseUrl: dbPath }) const jwks2 = await second.inject({ method: 'GET', url: '/.well-known/jwks.json', }) expect(jwks2.statusCode).toBe(200) expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe( kid, ) await second.close() } finally { try { rmSync(dir, { recursive: true, force: true }) } catch { // Windows may keep better-sqlite3 handle briefly } } }) it('authorization code flow issues id_token with groups', async () => { const app = await buildTestApp() const login = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'admin@test.local', password: 'adminpass' }, }) expect(login.statusCode).toBe(200) const token = (login.json() as { access_token: string }).access_token const refresh = login.cookies.find((c) => c.name === 'refresh_token') expect(refresh?.value).toBeTruthy() const created = await app.inject({ method: 'POST', url: '/api/v1/admin/oidc/clients', headers: { authorization: `Bearer ${token}` }, payload: { name: 'Technitium', redirect_uris: ['https://dns.test.local/sso/callback'], scopes: ['openid', 'profile', 'email', 'groups'], enabled: true, }, }) expect(created.statusCode).toBe(200) const client = created.json() as { client_id: string client_secret: string } const authorize = await app.inject({ method: 'GET', url: '/oauth/authorize?' + new URLSearchParams({ client_id: client.client_id, redirect_uri: 'https://dns.test.local/sso/callback', response_type: 'code', scope: 'openid profile email groups', state: 'xyz', nonce: 'n1', }).toString(), cookies: { refresh_token: refresh!.value }, }) expect(authorize.statusCode).toBe(302) const location = authorize.headers.location! expect(location).toContain('https://dns.test.local/sso/callback') const code = new URL(location).searchParams.get('code') expect(code).toBeTruthy() const tokenRes = await app.inject({ method: 'POST', url: '/oauth/token', payload: { grant_type: 'authorization_code', code: code!, redirect_uri: 'https://dns.test.local/sso/callback', client_id: client.client_id, client_secret: client.client_secret, }, }) expect(tokenRes.statusCode).toBe(200) const tokens = tokenRes.json() as { access_token: string id_token: string token_type: string } expect(tokens.token_type).toBe('Bearer') const jwksRes = await app.inject({ method: 'GET', url: '/.well-known/jwks.json', }) const jwks = createLocalJWKSet(jwksRes.json() as { keys: never[] }) const { payload } = await jwtVerify(tokens.id_token, jwks, { issuer: 'https://auth.test.local', audience: client.client_id, }) expect(payload.sub).toBeTruthy() expect(payload.email).toBe('admin@test.local') expect(payload.nonce).toBe('n1') const groups = payload.groups as string[] expect(groups).toContain('technitium_admins') expect(groups).toContain('technitium_dns_admins') const userinfo = await app.inject({ method: 'GET', url: '/oauth/userinfo', headers: { authorization: `Bearer ${tokens.access_token}` }, }) expect(userinfo.statusCode).toBe(200) const info = userinfo.json() as { groups: string[]; email: string } expect(info.email).toBe('admin@test.local') expect(info.groups).toContain('technitium_admins') await app.close() }) it('rejects invalid client secret and redirect_uri mismatch', async () => { const app = await buildTestApp() const login = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'admin@test.local', password: 'adminpass' }, }) const token = (login.json() as { access_token: string }).access_token const refresh = login.cookies.find((c) => c.name === 'refresh_token')! const created = await app.inject({ method: 'POST', url: '/api/v1/admin/oidc/clients', headers: { authorization: `Bearer ${token}` }, payload: { name: 'DNS', redirect_uris: ['https://dns.test.local/sso/callback'], scopes: ['openid', 'profile', 'email', 'groups'], enabled: true, }, }) const client = created.json() as { client_id: string client_secret: string } const badRedirect = await app.inject({ method: 'GET', url: '/oauth/authorize?' + new URLSearchParams({ client_id: client.client_id, redirect_uri: 'https://evil.test/callback', response_type: 'code', scope: 'openid', }).toString(), cookies: { refresh_token: refresh.value }, }) expect(badRedirect.statusCode).toBe(400) const authorize = await app.inject({ method: 'GET', url: '/oauth/authorize?' + new URLSearchParams({ client_id: client.client_id, redirect_uri: 'https://dns.test.local/sso/callback', response_type: 'code', scope: 'openid', }).toString(), cookies: { refresh_token: refresh.value }, }) const code = new URL(authorize.headers.location!).searchParams.get('code')! const badSecret = await app.inject({ method: 'POST', url: '/oauth/token', payload: { grant_type: 'authorization_code', code, redirect_uri: 'https://dns.test.local/sso/callback', client_id: client.client_id, client_secret: 'wrong-secret', }, }) expect(badSecret.statusCode).toBe(401) await app.close() }) })