/** * Tests integration auth middleware — dual mode service-token + OIDC. * * 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 { Decimal } from 'decimal.js'; import { Hono } from 'hono'; import { type CryptoKey, type JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Personne } from '../../src/domain/personne.js'; import { logger } from '../../src/lib/logger.js'; import { type ApiTokenRecord, type AuthVariables, authMiddleware, 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); } // --------------------------------------------------------------------------- // Fakes pour cache + finder // --------------------------------------------------------------------------- class FakeCache { store = new Map(); hits = 0; setCalls = 0; async get(key: string): Promise { if (this.store.has(key)) { this.hits += 1; return this.store.get(key) as T; } return null; } async set(key: string, value: T, _ttl?: number): Promise { this.setCalls += 1; this.store.set(key, value); } } class FakeFinder { byEmail = new Map(); calls = 0; async findByEmail(email: string): Promise { this.calls += 1; return this.byEmail.get(email.toLowerCase()) ?? null; } } function makePersonne(opts: { id: number; email: string; roles: Array<'formateur' | 'developpeur' | 'admin' | 'direction' | 'support'>; }): Personne { return new Personne({ id: opts.id, nom: 'Doe', prenom: 'Jane', email: opts.email, capaciteAnnuelle: new Decimal(1500), splitFormationPct: new Decimal(60), splitAgencePct: new Decimal(40), roles: new Set(opts.roles), statut: 'actif', }); } // --------------------------------------------------------------------------- // App builder // --------------------------------------------------------------------------- interface BuildAppOpts { tokens?: ApiTokenRecord[]; oidcEnabled: boolean; strictMapping?: boolean; cache?: FakeCache; finder?: FakeFinder; 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); const cache = opts.cache ?? new FakeCache(); const finder = opts.finder ?? new FakeFinder(); 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 ?? {}, strictMapping: opts.strictMapping ?? true, cache, finder, logger, }), ); app.get('/protected/me', (c) => { const user = c.get('user'); return c.json({ user }); }); app.get('/protected/needs-formation-read', requireScope('formation:read'), (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, cache, finder }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('parseTokens', () => { it('parse JSON valide', () => { const map = parseTokens( JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:personnes'] }]), ); 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:personnes']), 'read:personnes')).toBe(true); expect(hasScope(new Set(['read:personnes']), 'read:projets')).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:personnes')).toBe(true); expect(hasScope(new Set(['read:*']), 'write:personnes')).toBe(false); }); }); describe('auth middleware — service tokens (mode local)', () => { const tokens: ApiTokenRecord[] = [ { token: 'brg_valid', name: 'demo', scopes: ['formation:read', '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('formation:read'); }); 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', () => { let jwks: JwksFixture; beforeAll(async () => { jwks = await startJwksServer(); }); afterAll(async () => { await new Promise((resolve) => jwks.server.close(() => resolve())); }); it('cas 4 — JWT valid + email -> Personne -> 200, source=oidc-jwt, roles', async () => { const finder = new FakeFinder(); finder.byEmail.set( 'jane@acadenice.fr', makePersonne({ id: 42, email: 'jane@acadenice.fr', roles: ['formateur'] }), ); const { app } = buildApp({ oidcEnabled: true, jwks, finder }); const token = await signJwt(jwks, { email: 'jane@acadenice.fr', sub: 'authentik-jane-uuid', groups: ['formation-hub-formateurs'], }); 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; personneId: number; roles: string[]; scopes: string[]; sub: string }; }; expect(body.user.source).toBe('oidc-jwt'); expect(body.user.personneId).toBe(42); expect(body.user.roles).toContain('formateur'); expect(body.user.sub).toBe('authentik-jane-uuid'); // Default role->scope mapping pour formateur inclut formation:* style write:attributions expect(body.user.scopes).toContain('write:attributions'); }); 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' }); // Tamper signature : remplace les 16 derniers chars par des '0' base64url valides. // Garantit que la signature change vraiment (un seul char flip peut tomber sur // une variation base64 qui code la meme valeur binaire). 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 — JWT email orphelin (mode strict) -> 403 FORBIDDEN', async () => { const { app, finder } = buildApp({ oidcEnabled: true, jwks, strictMapping: true }); const token = await signJwt(jwks, { email: 'nobody@acadenice.fr', sub: 'authentik-nobody', groups: [], }); const res = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(403); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('FORBIDDEN'); expect(finder.calls).toBe(1); }); it('mode permissif : email orphelin -> 200 avec scopes des groups uniquement', async () => { const { app } = buildApp({ oidcEnabled: true, jwks, strictMapping: false, groupsScopesMap: { 'formation-hub-formateurs': ['formation:read'] }, }); const token = await signJwt(jwks, { email: 'nobody@acadenice.fr', groups: ['formation-hub-formateurs'], }); 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[]; personneId?: number; roles: string[] }; }; expect(body.user.scopes).toContain('formation:read'); expect(body.user.personneId).toBeUndefined(); expect(body.user.roles).toEqual([]); }); it('cas 10 — Cookie authToken valid -> 200, source=oidc-cookie', async () => { const finder = new FakeFinder(); finder.byEmail.set( 'cookie@acadenice.fr', makePersonne({ id: 7, email: 'cookie@acadenice.fr', roles: ['developpeur'] }), ); const { app } = buildApp({ oidcEnabled: true, jwks, finder }); const token = await signJwt(jwks, { email: 'cookie@acadenice.fr', groups: [] }); 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; personneId: number } }; expect(body.user.source).toBe('oidc-cookie'); expect(body.user.personneId).toBe(7); }); it('cas 11 — requireScope match via groups Authentik -> 200', async () => { const finder = new FakeFinder(); finder.byEmail.set( 'fmt@acadenice.fr', makePersonne({ id: 1, email: 'fmt@acadenice.fr', roles: [] }), ); const { app } = buildApp({ oidcEnabled: true, jwks, finder, groupsScopesMap: { 'formation-hub-formateurs': ['formation:read'] }, }); const token = await signJwt(jwks, { email: 'fmt@acadenice.fr', groups: ['formation-hub-formateurs'], }); const res = await app.request('/protected/needs-formation-read', { 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('formation:read'); }); it('cas 12 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => { const finder = new FakeFinder(); finder.byEmail.set( 'fmt2@acadenice.fr', makePersonne({ id: 2, email: 'fmt2@acadenice.fr', roles: ['formateur'] }), ); const { app } = buildApp({ oidcEnabled: true, jwks, finder }); const token = await signJwt(jwks, { email: 'fmt2@acadenice.fr', groups: [] }); // formateur n'a pas admin:write par defaut. 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'); }); it("cache hit : 2 requetes consecutives ne tapent qu'une fois le repo", async () => { const finder = new FakeFinder(); finder.byEmail.set( 'cached@acadenice.fr', makePersonne({ id: 99, email: 'cached@acadenice.fr', roles: ['developpeur'] }), ); const cache = new FakeCache(); const { app } = buildApp({ oidcEnabled: true, jwks, finder, cache }); const token = await signJwt(jwks, { email: 'cached@acadenice.fr', groups: [] }); const res1 = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res1.status).toBe(200); const res2 = await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` }, }); expect(res2.status).toBe(200); expect(finder.calls).toBe(1); expect(cache.hits).toBe(1); }); it('cache miss persist : email inexistant => second appel hit cache', async () => { const finder = new FakeFinder(); const cache = new FakeCache(); const { app } = buildApp({ oidcEnabled: true, jwks, finder, cache, strictMapping: false, }); const token = await signJwt(jwks, { email: 'ghost@acadenice.fr', groups: [] }); await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` } }); await app.request('/protected/me', { headers: { Authorization: `Bearer ${token}` } }); expect(finder.calls).toBe(1); }); });