/** * Tests integration auth middleware (R1 generique) — dual mode service-token + OIDC. * * R1 — Plus de lookup PersonneRepo, plus de roles formation-hub. Le claim * `acadenice_permissions[]` du JWT alimente directement les scopes. * * Strategie JWKS : mini serveur HTTP local qui expose un /.well-known/jwks.json * avec une cle RSA generee a la volee via `jose.generateKeyPair`. Plus realiste * que mocker `createRemoteJWKSet` car teste fetch reel + parsing reel. */ import { type Server, createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import { Hono } from 'hono'; import { type CryptoKey, type JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { logger } from '../../src/lib/logger.js'; import { type ApiTokenRecord, type AuthVariables, authMiddleware, extractPermissions, hasScope, parseTokens, requireScope, } from '../../src/middleware/auth.js'; import { errorHandler } from '../../src/middleware/error-handler.js'; import { OidcVerifier } from '../../src/middleware/oidc-verifier.js'; // --------------------------------------------------------------------------- // JWKS mini server // --------------------------------------------------------------------------- interface JwksFixture { server: Server; url: string; privateKey: CryptoKey; publicJwk: JWK; kid: string; hits: number; } async function startJwksServer(): Promise { const { privateKey, publicKey } = await generateKeyPair('RS256'); const publicJwk = await exportJWK(publicKey); const kid = 'test-key-1'; publicJwk.kid = kid; publicJwk.alg = 'RS256'; publicJwk.use = 'sig'; const fixture: JwksFixture = { server: createServer(), url: '', privateKey, publicJwk, kid, hits: 0, }; fixture.server.on('request', (req, res) => { if (req.url === '/jwks.json') { fixture.hits += 1; res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ keys: [publicJwk] })); return; } res.writeHead(404); res.end(); }); await new Promise((resolve) => fixture.server.listen(0, '127.0.0.1', resolve)); const addr = fixture.server.address() as AddressInfo; fixture.url = `http://127.0.0.1:${addr.port}/jwks.json`; return fixture; } async function signJwt( fx: JwksFixture, claims: Record, overrides?: { issuer?: string; audience?: string; expiresIn?: string; alg?: string }, ): Promise { const builder = new SignJWT(claims) .setProtectedHeader({ alg: overrides?.alg ?? 'RS256', kid: fx.kid }) .setIssuedAt() .setIssuer(overrides?.issuer ?? 'https://auth.test/issuer') .setAudience(overrides?.audience ?? 'formation-hub-bridge') .setExpirationTime(overrides?.expiresIn ?? '5m'); return builder.sign(fx.privateKey); } // --------------------------------------------------------------------------- // App builder // --------------------------------------------------------------------------- interface BuildAppOpts { tokens?: ApiTokenRecord[]; oidcEnabled: boolean; jwks?: JwksFixture; groupsScopesMap?: Record; } function buildApp(opts: BuildAppOpts) { const tokens = opts.tokens ?? []; const map = new Map(); for (const t of tokens) map.set(t.token, t); let verifier: OidcVerifier | null = null; if (opts.oidcEnabled) { if (!opts.jwks) throw new Error('jwks fixture required when oidcEnabled'); verifier = new OidcVerifier({ issuer: 'https://auth.test/issuer', jwksUri: opts.jwks.url, audience: 'formation-hub-bridge', logger, jwksCacheMaxAgeMs: 1000, }); } const app = new Hono<{ Variables: AuthVariables }>(); app.onError(errorHandler); app.use( '/protected/*', authMiddleware({ tokens: map, oidc: verifier, groupsScopesMap: opts.groupsScopesMap ?? {}, logger, }), ); app.get('/protected/me', (c) => { const user = c.get('user'); return c.json({ user }); }); app.get('/protected/needs-read-tables', requireScope('read:tables'), (c) => c.json({ ok: true, scopes: c.get('user').scopes }), ); app.get('/protected/needs-admin', requireScope('admin:write'), (c) => c.json({ ok: true })); return { app }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('parseTokens', () => { it('parse JSON valide', () => { const map = parseTokens( JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:tables'] }]), ); expect(map.get('brg_x')?.name).toBe('a'); }); it('retourne map vide si raw vide', () => { expect(parseTokens(undefined).size).toBe(0); expect(parseTokens('').size).toBe(0); }); it('throw si JSON invalide', () => { expect(() => parseTokens('{nope')).toThrow(/JSON/); }); it('throw si pas un array', () => { expect(() => parseTokens('{"foo": 1}')).toThrow(/tableau/); }); it('throw si entree manque token/name/scopes', () => { expect(() => parseTokens('[{"token":"x"}]')).toThrow(); expect(() => parseTokens('[{"token":"x","name":"y"}]')).toThrow(); expect(() => parseTokens('[{"token":"x","name":"y","scopes":[1]}]')).toThrow(); }); }); describe('hasScope', () => { it('match exact', () => { expect(hasScope(new Set(['read:tables']), 'read:tables')).toBe(true); expect(hasScope(new Set(['read:tables']), 'write:tables')).toBe(false); }); it('admin:* couvre tout', () => { expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true); }); it('prefix wildcard couvre meme prefix', () => { expect(hasScope(new Set(['read:*']), 'read:tables')).toBe(true); expect(hasScope(new Set(['read:*']), 'write:tables')).toBe(false); }); }); describe('extractPermissions', () => { it('retourne le claim acadenice_permissions[] si present', () => { expect(extractPermissions({ acadenice_permissions: ['a', 'b'] })).toEqual(['a', 'b']); }); it('retourne [] si claim absent', () => { expect(extractPermissions({})).toEqual([]); }); it('retourne [] si claim pas un array', () => { expect(extractPermissions({ acadenice_permissions: 'foo' })).toEqual([]); }); it('filtre les valeurs non-strings et vides', () => { expect(extractPermissions({ acadenice_permissions: ['ok', 1, '', null, 'good'] })).toEqual([ 'ok', 'good', ]); }); }); describe('auth middleware — service tokens', () => { const tokens: ApiTokenRecord[] = [ { token: 'brg_valid', name: 'demo', scopes: ['read:tables', 'admin:write'] }, ]; it('cas 1 — service token valid via Bearer -> 200 + source=service-token', async () => { const { app } = buildApp({ tokens, oidcEnabled: false }); const res = await app.request('/protected/me', { headers: { Authorization: 'Bearer brg_valid' }, }); expect(res.status).toBe(200); const body = (await res.json()) as { user: { source: string; scopes: string[]; tokenId: string }; }; expect(body.user.source).toBe('service-token'); expect(body.user.tokenId).toBe('demo'); expect(body.user.scopes).toContain('read:tables'); }); it('service token valid via ApiKey scheme -> 200', async () => { const { app } = buildApp({ tokens, oidcEnabled: false }); const res = await app.request('/protected/me', { headers: { Authorization: 'ApiKey brg_valid' }, }); expect(res.status).toBe(200); }); it('cas 2 — service token invalid -> 401 AUTH_INVALID', async () => { const { app } = buildApp({ tokens, oidcEnabled: false }); const res = await app.request('/protected/me', { headers: { Authorization: 'Bearer brg_unknown' }, }); expect(res.status).toBe(401); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('AUTH_INVALID'); }); it('header sans scheme reconnu -> 401 AUTH_INVALID', async () => { const { app } = buildApp({ tokens, oidcEnabled: false }); const res = await app.request('/protected/me', { headers: { Authorization: 'Token brg_valid' }, }); expect(res.status).toBe(401); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('AUTH_INVALID'); }); it('aucun header -> 401 AUTH_REQUIRED', async () => { const { app } = buildApp({ tokens, oidcEnabled: false }); const res = await app.request('/protected/me'); expect(res.status).toBe(401); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('AUTH_REQUIRED'); }); }); describe('auth middleware — OIDC desactive + JWT envoye', () => { it('cas 3 — OIDC off + Bearer non-brg -> 401 (pas de fallback silencieux)', async () => { const { app } = buildApp({ oidcEnabled: false }); const res = await app.request('/protected/me', { headers: { Authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.fake.fake' }, }); expect(res.status).toBe(401); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('AUTH_INVALID'); }); it('OIDC off + cookie authToken -> 401', async () => { const { app } = buildApp({ oidcEnabled: false }); const res = await app.request('/protected/me', { headers: { Cookie: 'authToken=eyJhbGciOiJSUzI1NiJ9.fake.fake' }, }); expect(res.status).toBe(401); }); }); describe('auth middleware — OIDC actif (R1 generique)', () => { let jwks: JwksFixture; beforeAll(async () => { jwks = await startJwksServer(); }); afterAll(async () => { await new Promise((resolve) => jwks.server.close(() => resolve())); }); it('cas 4 — JWT valid + claim acadenice_permissions[] -> 200, scopes alimentes directement', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'jane@acadenice.fr', sub: 'authentik-jane-uuid', groups: [], acadenice_permissions: ['read:tables', 'write:tables'], }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { user: { source: string; scopes: string[]; permissions: string[]; sub: string; email: string; }; }; expect(body.user.source).toBe('oidc-jwt'); expect(body.user.email).toBe('jane@acadenice.fr'); expect(body.user.sub).toBe('authentik-jane-uuid'); expect(body.user.scopes).toContain('read:tables'); expect(body.user.scopes).toContain('write:tables'); expect(body.user.permissions).toEqual(['read:tables', 'write:tables']); }); it('cas 4b — JWT sans claim acadenice_permissions[] : auth OK mais scopes vides', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'jane@acadenice.fr', sub: 'sub-x', groups: [], }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { user: { permissions: string[]; scopes: string[] } }; expect(body.user.permissions).toEqual([]); expect(body.user.scopes).toEqual([]); }); it('cas 4c — JWT avec groups Authentik mappes -> scopes via groupsScopesMap', async () => { const { app } = buildApp({ oidcEnabled: true, jwks, groupsScopesMap: { 'role-formateur': ['read:tables'] }, }); const token = await signJwt(jwks, { email: 'fmt@acadenice.fr', groups: ['role-formateur'], }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { user: { scopes: string[]; groups: string[] } }; expect(body.user.scopes).toContain('read:tables'); expect(body.user.groups).toContain('role-formateur'); }); it('cas 4d — union groups + permissions explicites', async () => { const { app } = buildApp({ oidcEnabled: true, jwks, groupsScopesMap: { 'role-x': ['read:tables'] }, }); const token = await signJwt(jwks, { email: 'x@acadenice.fr', groups: ['role-x'], acadenice_permissions: ['write:tables'], }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); const body = (await res.json()) as { user: { scopes: string[] } }; expect(body.user.scopes).toContain('read:tables'); expect(body.user.scopes).toContain('write:tables'); }); it('cas 5 — JWT signature invalide -> 401 AUTH_INVALID', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'x@y.z' }); const tampered = `${token.slice(0, -16)}AAAAAAAAAAAAAAAA`; const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${tampered}` }, }); expect(res.status).toBe(401); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('AUTH_INVALID'); }); it('cas 6 — JWT expired -> 401', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'x@y.z' }, { expiresIn: '-1s' }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(401); }); it('cas 7 — JWT issuer different -> 401', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'x@y.z' }, { issuer: 'https://evil.example/' }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(401); }); it('cas 8 — JWT audience different -> 401', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'x@y.z' }, { audience: 'other-app' }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(401); }); it('cas 9 — Cookie authToken valid -> 200, source=oidc-cookie', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'cookie@acadenice.fr', acadenice_permissions: ['read:tables'], }); const res = await app.request('/protected/me', { headers: { Cookie: `authToken=${token}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { user: { source: string; email: string } }; expect(body.user.source).toBe('oidc-cookie'); expect(body.user.email).toBe('cookie@acadenice.fr'); }); it('cas 10 — requireScope match via permissions claim -> 200', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'a@b.c', acadenice_permissions: ['read:tables'], }); const res = await app.request('/protected/needs-read-tables', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; scopes: string[] }; expect(body.ok).toBe(true); expect(body.scopes).toContain('read:tables'); }); it('cas 11 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => { const { app } = buildApp({ oidcEnabled: true, jwks }); const token = await signJwt(jwks, { email: 'a@b.c', acadenice_permissions: ['read:tables'], }); const res = await app.request('/protected/needs-admin', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(403); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('FORBIDDEN_SCOPE'); }); });