Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
- Support JWT OIDC Authentik via jose + JWKS (cache 10min) - Lookup Personne via PersonneRepo.findByEmail + cache Redis 60s - Mapping groups Authentik + roles formation-hub vers scopes - Mode OIDC active uniquement si AUTHENTIK_ISSUER + JWKS_URI + AUDIENCE set - Service tokens brg_* inchanges, restent voie principale en local
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
/**
|
|
* 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<JwksFixture> {
|
|
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<void>((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<string, unknown>,
|
|
overrides?: { issuer?: string; audience?: string; expiresIn?: string; alg?: string },
|
|
): Promise<string> {
|
|
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<string, unknown>();
|
|
hits = 0;
|
|
setCalls = 0;
|
|
|
|
async get<T>(key: string): Promise<T | null> {
|
|
if (this.store.has(key)) {
|
|
this.hits += 1;
|
|
return this.store.get(key) as T;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async set<T>(key: string, value: T, _ttl?: number): Promise<void> {
|
|
this.setCalls += 1;
|
|
this.store.set(key, value);
|
|
}
|
|
}
|
|
|
|
class FakeFinder {
|
|
byEmail = new Map<string, Personne>();
|
|
calls = 0;
|
|
|
|
async findByEmail(email: string): Promise<Personne | null> {
|
|
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<string, string[]>;
|
|
}
|
|
|
|
function buildApp(opts: BuildAppOpts) {
|
|
const tokens = opts.tokens ?? [];
|
|
const map = new Map<string, ApiTokenRecord>();
|
|
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<void>((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);
|
|
});
|
|
});
|