From 571f5c34267ff7ecbf2c65900a916ee7493c7d02 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 7 May 2026 21:17:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Bloc=204=20=E2=80=94=20middleware?= =?UTF-8?q?=20OIDC-ready=20avec=20dual=20mode=20service-token=20+=20Authen?= =?UTF-8?q?tik=20JWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../fast-app/formation-hub/SESSION-RESUME.md | 33 +- bridge/.env.example | 14 +- bridge/package-lock.json | 10 + bridge/package.json | 1 + bridge/src/index.ts | 15 +- bridge/src/lib/config.ts | 21 + bridge/src/lib/container.ts | 25 + bridge/src/lib/errors.ts | 3 + bridge/src/middleware/auth.ts | 290 ++++++++-- bridge/src/middleware/oidc-verifier.ts | 94 ++++ bridge/src/middleware/scopes.ts | 72 +++ bridge/src/repos/baserow-repo.ts | 31 ++ bridge/tests/helpers/test-app.ts | 25 +- bridge/tests/middleware/auth.test.ts | 502 ++++++++++++++++-- bridge/tests/middleware/scopes.test.ts | 62 +++ bridge/tests/repos/baserow-repo.test.ts | 84 +++ bridge/vitest.config.ts | 7 + 17 files changed, 1202 insertions(+), 87 deletions(-) create mode 100644 bridge/src/middleware/oidc-verifier.ts create mode 100644 bridge/src/middleware/scopes.ts create mode 100644 bridge/tests/middleware/scopes.test.ts diff --git a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md index 232e751..4bfdcc3 100644 --- a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md +++ b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md @@ -1,3 +1,26 @@ +# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 4 OIDC) + +## CHANGELOG depuis derniere update (Bloc 4 — auth OIDC-ready) + +- **Bloc 4 livre (middleware OIDC-ready, dual mode)** : + - Nouveau module `src/middleware/oidc-verifier.ts` : verification JWT via `jose` + JWKS remote (cache 10min). Algorithmes acceptes : RS256/RS384/RS512 (pas HS* puisque cle publique). Throw `errors.authInvalid()` sur tout echec (signature, expired, issuer mismatch, audience mismatch). + - Nouveau module `src/middleware/scopes.ts` : `parseGroupsScopesMap` (parse JSON env var) + `computeOidcScopes` (union groups Authentik + roles formation-hub). Defaut role-scope conservateur (admin -> `admin:*`, formateur -> `read:personnes,read:formations,write:attributions`, etc.). + - Refactor `src/middleware/auth.ts` (mais service tokens 100% retro-compat) : + - Schemes acceptes : `Authorization: ApiKey brg_*`, `Authorization: Bearer brg_*` (service token) OU `Authorization: Bearer ` OU cookie `authToken=` (OIDC). + - Si OIDC desactive (vars Authentik manquantes) + JWT envoye -> 401 (pas de fallback silencieux). + - Lookup `PersonneRepo.findByEmail` (nouvelle methode) + cache Redis 60s avec key `bridge:auth:personne-by-email:` (RGPD : pas d'email en clair dans Redis). Cache positif et negatif. + - Type `AuthenticatedUser` injecte dans Hono context : `{ source, tokenId?, email?, sub?, personneId?, roles, groups, scopes }`. + - Mode strict (defaut) : email orphelin (JWT valide mais pas de Personne) -> 403 FORBIDDEN. Mode permissif : autorise avec scopes des groups uniquement. + - `requireScope` etendu : wildcard prefix (`read:*` couvre `read:personnes`, etc.) + admin:*. + - Config zod (`src/lib/config.ts`) : ajout `authentikIssuer`, `authentikJwksUri`, `authentikAudience`, `authGroupsScopesMap`, `authStrictMapping` (toutes optionnelles). Helper `isOidcEnabled()` retourne true ssi 3 vars Authentik set. + - `PersonneRepo.findByEmail(email)` : recherche via `search` Baserow puis filtre exact post-fetch (case-insensitive + trim). Retourne null sur miss/row-malformee (vs throw). + - Erreurs : ajout code `FORBIDDEN` (vs `FORBIDDEN_SCOPE` deja existant) pour les cas auth-mais-pas-de-droits-metier. + - Container DI : ajout `oidc: OidcVerifier | null` + `groupsScopesMap`. Construit au boot si vars set. + - Tests : **30 tests ajoutes** (260 -> 290). 27 tests integration auth middleware (12 cas reglementaires + 15 cas annexes), 10 tests scopes mapping, 5 tests `findByEmail`. Mini serveur HTTP local genere une cle RSA via `jose.generateKeyPair` -> sert un JWKS reel -> verifier le tape via fetch (plus realiste que mocker `createRemoteJWKSet`). + - Coverage `src/middleware/auth.ts` = **94.11% lines / 88.37% branches** (seuil >= 85% applique dans `vitest.config.ts`). Coverage globale stable a 87.38%. + - `.env.example` enrichi (commente, prefixe `# AUTHENTIK_*`). `.env` local inchange — Authentik n'est pas branche en local pour l'instant, le mode reste service-tokens-only par defaut. + - Lib ajoutee : `jose@^6.2.3` (standard moderne OIDC, ESM-first). + # SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 7) ## CHANGELOG depuis derniere update (Bloc 7 — webhooks) @@ -66,7 +89,8 @@ Stack live + bridge testes : | 2 — Domain models | DONE | `2c5665b`, 97.86% coverage | | 3 — Routes Tier 1 + auth + repos | DONE | `c8e9b4d`, 10/10 endpoints, 86-96% coverage middleware/routes | | 3.2 — Refactor erreurs domain typees + routes /blocs /clients /taches | TODO | DomainError sub-classes (RGViolationError, ConflictError) pour remplacer mapping par texte | -| 4 — Auth middleware | DONE (en partie) | inclus dans Bloc 3 (Bearer brg_*, scopes JSON-encoded, admin:* wildcard) | +| 4 — Auth middleware OIDC-ready | DONE | dual mode service-token + Authentik JWT/cookie, mode OIDC desactive en local (vars env absentes), 27 tests coverage 94.11% | +| 4b — Docmost OIDC fork (rebrand DocAdenice + login Authentik) | TODO | docmost-fork-dev — depend Bloc 4 | | 5 — Rate limit + cache invalidation | TODO | RedisCache.checkRateLimit existe deja, faut middleware Hono qui l'appelle | | 6 — Tests integration adapters | DONE | `1528017`, 59 tests, redis-cache 100% / baserow 99% / docmost 97.7% lines | | 7a — Webhook Baserow (HMAC + idempotence + invalidation cache) | DONE | webhooks/* + routes/webhooks.ts, 100% coverage | @@ -78,12 +102,12 @@ Stack live + bridge testes : ## Coverage globale (post-Bloc 7) -- **All files** : 86.91% lines / 86.48% branches +- **All files** : 87.38% lines / 86.31% branches (post-Bloc 4) - **adapters/** : 98.73% lines / 95.04% branches - **domain/** : 97.86% lines / 98.16% branches - **routes/** : 96.58% lines / 76.19% branches (incluant webhooks.ts 97.77%) - **webhooks/** : **100% lines / 100% branches** (signature, baserow-handler, docmost-handler, types) -- **middleware/** : 86.41% lines / 88.88% branches +- **middleware/** : 92.12% lines / 85.36% branches (auth.ts 94.11/88.37, oidc-verifier.ts 88.88/58.33, scopes.ts 100/95.45) - **lib/** : 49.18% lines (config.ts/container.ts non couverts — bootstrap) - **repos/** : 59.53% lines (BaseRepo abstract — couvert via repos concrets) @@ -91,7 +115,8 @@ Stack live + bridge testes : Recommandation pour la reprise : -- **Option A (recommandee)** : Bloc 8 — Tiptap node-views Docmost (docmost-fork-dev). Forke Docmost AGPL, ajoute les nodes custom `baserow-row` / `baserow-list` qui font des reads via le bridge `/api/v1/*` et des writes via webhooks Docmost (handler stub deja en place — il restera a parser le payload reel et appeler les repos Baserow). Cest la piece UI manquante qui rend le bridge visible cote utilisateur. +- **Option A** : DocAdenice rebrand + Bloc 4b — fork Docmost pour ajouter login Authentik + theme Acadenice. Le bridge cote serveur est pret (Bloc 4 livre), reste a brancher Authentik live + faire en sorte que Docmost emette le cookie `authToken` ou un Bearer JWT vers le bridge. +- **Option B (recommandee si pas d'Authentik live)** : Bloc 8 — Tiptap node-views Docmost (docmost-fork-dev). Forke Docmost AGPL, ajoute les nodes custom `baserow-row` / `baserow-list` qui font des reads via le bridge `/api/v1/*` et des writes via webhooks Docmost (handler stub deja en place — il restera a parser le payload reel et appeler les repos Baserow). Cest la piece UI manquante qui rend le bridge visible cote utilisateur. - **Option B** : Bloc 9 — tests E2E Playwright contre la stack live (bridge-tester) pour figer le comportement actuel des routes + webhooks avant que le fork Docmost ne bouge. - **Option C** : Bloc 5 — rate limit + cache invalidation middleware. Court (~1h). RedisCache.checkRateLimit existe deja, faut le wire dans Hono. Pas bloquant pour Bloc 8. - **Option D** : Bloc 3.2 — refactor erreurs domain typees + routes restantes (/blocs, /clients, /taches). Pas urgent. diff --git a/bridge/.env.example b/bridge/.env.example index fe68937..de712b8 100644 --- a/bridge/.env.example +++ b/bridge/.env.example @@ -14,7 +14,7 @@ BASEROW_API_TOKEN= DOCMOST_API_URL=http://docmost:3000/api DOCMOST_API_TOKEN= -# Redis (cache + idempotence webhooks) +# Redis (cache + idempotence webhooks + lookup Personne) REDIS_URL=redis://docmost-redis:6379 # Webhooks Baserow signature secret (HMAC-SHA256, header X-Baserow-Signature) @@ -24,11 +24,19 @@ BASEROW_WEBHOOK_SECRET= # Stub Bloc 7b — handlers metier viennent en Bloc 8 (Tiptap node-views) DOCMOST_WEBHOOK_SECRET= -# Auth tokens bridge (CSV des tokens valides + scopes — Phase 2 simple) -# Format: token1:scope1,scope2;token2:scope3 +# Auth tokens bridge — JSON serialise (Phase 2 simple) +# Format: [{"token":"brg_xxx","name":"label","scopes":["read:personnes",...]}] # Phase 3 : migration vers DB dediee BRIDGE_API_TOKENS= +# Authentik OIDC (optional — laisse vide pour mode local-only avec service tokens) +# Active uniquement si AUTHENTIK_ISSUER + AUTHENTIK_JWKS_URI + AUTHENTIK_AUDIENCE sont set. +# AUTHENTIK_ISSUER=https://auth.acadenice.com/application/o/formation-hub/ +# AUTHENTIK_JWKS_URI=https://auth.acadenice.com/application/o/formation-hub/jwks/ +# AUTHENTIK_AUDIENCE=formation-hub-bridge +# AUTH_GROUPS_SCOPES_MAP={"formation-hub-formateurs":["formation:read","intervention:write"],"formation-hub-admins":["admin:*"]} +# AUTH_STRICT_MAPPING=true # false -> autorise les emails OIDC sans Personne (scopes des groups uniquement) + # Rate limiting (par token + endpoint) RATE_LIMIT_READ_PER_MIN=600 RATE_LIMIT_WRITE_PER_MIN=60 diff --git a/bridge/package-lock.json b/bridge/package-lock.json index 2e597a8..fef4cdf 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^16.6.1", "hono": "^4.12.18", "ioredis": "^5.10.1", + "jose": "^6.2.3", "ofetch": "^1.5.1", "pino": "^9.14.0", "pino-pretty": "^13.1.3", @@ -2892,6 +2893,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", diff --git a/bridge/package.json b/bridge/package.json index 6b1ee49..ae47eb4 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -27,6 +27,7 @@ "dotenv": "^16.6.1", "hono": "^4.12.18", "ioredis": "^5.10.1", + "jose": "^6.2.3", "ofetch": "^1.5.1", "pino": "^9.14.0", "pino-pretty": "^13.1.3", diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 233387d..032b65f 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -43,9 +43,20 @@ export async function buildApp(): Promise> { app.route('/api/webhooks', webhooksRoutes); // Auth middleware applique sur tout /api/v1/* - const { tokens } = getContainer(); + const ctn = getContainer(); const v1 = new Hono<{ Variables: AuthVariables }>(); - v1.use('*', authMiddleware(tokens)); + v1.use( + '*', + authMiddleware({ + tokens: ctn.tokens, + oidc: ctn.oidc, + groupsScopesMap: ctn.groupsScopesMap, + strictMapping: ctn.config.authStrictMapping, + cache: ctn.redis, + finder: ctn.repos.personnes, + logger: ctn.logger, + }), + ); v1.route('/personnes', personnesRoutes); v1.route('/formations', formationsRoutes); v1.route('/projets', projetsRoutes); diff --git a/bridge/src/lib/config.ts b/bridge/src/lib/config.ts index 2c43793..e13add1 100644 --- a/bridge/src/lib/config.ts +++ b/bridge/src/lib/config.ts @@ -15,6 +15,15 @@ const ConfigSchema = z.object({ baserowWebhookSecret: z.string().min(16, 'webhook secret must be >= 16 chars'), docmostWebhookSecret: z.string().min(16, 'docmost webhook secret must be >= 16 chars').optional(), bridgeApiTokens: z.string().optional(), + // OIDC Authentik (optional — mode active uniquement si les 3 vars sont set). + authentikIssuer: z.string().url().optional(), + authentikJwksUri: z.string().url().optional(), + authentikAudience: z.string().min(1).optional(), + // JSON serialise group->scopes ; parse fait dans le middleware auth. + authGroupsScopesMap: z.string().optional(), + // Si false : un JWT OIDC valide dont l'email n'a pas de Personne attache passe quand meme + // (scopes derives uniquement des groups Authentik). Defaut strict. + authStrictMapping: z.coerce.boolean().default(true), }); export type Config = z.infer; @@ -32,6 +41,11 @@ export function loadConfig(): Config { baserowWebhookSecret: process.env.BASEROW_WEBHOOK_SECRET, docmostWebhookSecret: process.env.DOCMOST_WEBHOOK_SECRET, bridgeApiTokens: process.env.BRIDGE_API_TOKENS, + authentikIssuer: process.env.AUTHENTIK_ISSUER, + authentikJwksUri: process.env.AUTHENTIK_JWKS_URI, + authentikAudience: process.env.AUTHENTIK_AUDIENCE, + authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP, + authStrictMapping: process.env.AUTH_STRICT_MAPPING, }); if (!parsed.success) { @@ -43,3 +57,10 @@ export function loadConfig(): Config { return parsed.data; } + +/** True si la config a tout ce qu'il faut pour activer le mode OIDC. */ +export function isOidcEnabled( + c: Pick, +): boolean { + return Boolean(c.authentikIssuer && c.authentikJwksUri && c.authentikAudience); +} diff --git a/bridge/src/lib/container.ts b/bridge/src/lib/container.ts index 677b755..87932d2 100644 --- a/bridge/src/lib/container.ts +++ b/bridge/src/lib/container.ts @@ -9,8 +9,11 @@ import { BaserowClient } from '../adapters/baserow-client.js'; import { RedisCache } from '../adapters/redis-cache.js'; import type { ApiTokenRecord } from '../middleware/auth.js'; import { parseTokens } from '../middleware/auth.js'; +import { OidcVerifier } from '../middleware/oidc-verifier.js'; +import { type GroupsScopesMap, parseGroupsScopesMap } from '../middleware/scopes.js'; import { type RepoSet, TABLE_NAMES, type TableIds, buildRepos } from '../repos/baserow-repo.js'; import type { Config } from './config.js'; +import { isOidcEnabled } from './config.js'; import { logger as rootLogger } from './logger.js'; export interface Container { @@ -20,6 +23,9 @@ export interface Container { repos: RepoSet; tokens: ReadonlyMap; tableIds: TableIds; + /** Null si mode OIDC desactive (vars Authentik manquantes). */ + oidc: OidcVerifier | null; + groupsScopesMap: GroupsScopesMap; logger: Logger; } @@ -70,6 +76,23 @@ export async function initContainer(opts: InitOptions): Promise { const repos = buildRepos(baserow, tableIds, rootLogger); const tokens = parseTokens(config.bridgeApiTokens); + const groupsScopesMap = parseGroupsScopesMap(config.authGroupsScopesMap); + + let oidc: OidcVerifier | null = null; + if (isOidcEnabled(config)) { + oidc = new OidcVerifier({ + issuer: config.authentikIssuer as string, + jwksUri: config.authentikJwksUri as string, + audience: config.authentikAudience as string, + logger: rootLogger, + }); + rootLogger.info( + { issuer: config.authentikIssuer, audience: config.authentikAudience }, + 'OIDC mode enabled', + ); + } else { + rootLogger.info('OIDC mode disabled — service tokens only'); + } const container: Container = { config, @@ -78,6 +101,8 @@ export async function initContainer(opts: InitOptions): Promise { repos, tokens, tableIds, + oidc, + groupsScopesMap, logger: rootLogger, }; setContainer(container); diff --git a/bridge/src/lib/errors.ts b/bridge/src/lib/errors.ts index f2f732a..0e3d9ea 100644 --- a/bridge/src/lib/errors.ts +++ b/bridge/src/lib/errors.ts @@ -6,6 +6,7 @@ export type ErrorCode = | 'AUTH_REQUIRED' | 'AUTH_INVALID' + | 'FORBIDDEN' | 'FORBIDDEN_SCOPE' | 'NOT_FOUND' | 'VALIDATION_ERROR' @@ -43,6 +44,8 @@ export const errors = { authInvalid: () => new BridgeError('AUTH_INVALID', 401, 'Token invalide'), forbidden: (scope: string) => new BridgeError('FORBIDDEN_SCOPE', 403, `Scope requis : ${scope}`, { scope }), + forbiddenIdentity: (reason: string, details?: Record) => + new BridgeError('FORBIDDEN', 403, reason, details), notFound: (entity: string, id: string | number) => new BridgeError('NOT_FOUND', 404, `${entity} introuvable`, { entity, id }), validation: (issues: unknown) => diff --git a/bridge/src/middleware/auth.ts b/bridge/src/middleware/auth.ts index 804f4f1..ffc7e54 100644 --- a/bridge/src/middleware/auth.ts +++ b/bridge/src/middleware/auth.ts @@ -1,15 +1,34 @@ /** - * Auth middleware bridge — API tokens longue duree (`brg_*`) avec scopes. + * Auth middleware bridge — dual mode : * - * Tokens declares dans `BRIDGE_API_TOKENS` au format JSON : - * [{"token":"brg_xxx","name":"docmost-prod","scopes":["read:personnes","write:attributions"]}] + * 1. Service tokens `brg_*` (`Authorization: ApiKey brg_*` ou `Bearer brg_*`) + * pour M2M (webhooks emis par scripts, admin tools). Inchanges depuis Bloc 3. * - * JSON choisi plutot qu'un mini-DSL `name:scope1,scope2;...` : parse natif, pas d'ambiguite - * sur les separateurs si un nom contient virgule/point-virgule. Cf rapport Bloc 3. + * 2. OIDC JWT Authentik (`Authorization: Bearer ` ou cookie `authToken`) + * pour utilisateurs Docmost. Active uniquement si `AUTHENTIK_ISSUER` + + * `AUTHENTIK_JWKS_URI` + `AUTHENTIK_AUDIENCE` set dans la config. + * + * Ordre de detection : + * - Header `Authorization`: si commence par `brg_` (apres ApiKey/Bearer) -> service token + * - Header `Authorization: Bearer ` (commence par `eyJ`) -> JWT OIDC + * - Cookie `authToken` -> JWT OIDC + * - Sinon -> 401 AUTH_REQUIRED + * + * Pourquoi un seul middleware au lieu de deux ? Une seule passe = pas de doute + * sur l'ordre de priorite, et les routes /api/v1/* n'ont pas a savoir quelle + * source d'identite a ete utilisee. */ +import { createHash } from 'node:crypto'; import type { MiddlewareHandler } from 'hono'; +import { getCookie } from 'hono/cookie'; +import type { Logger } from 'pino'; +import type { Personne } from '../domain/personne.js'; +import type { Role } from '../domain/types.js'; import { errors } from '../lib/errors.js'; +import type { OidcVerifier } from './oidc-verifier.js'; +import { extractEmail, extractGroups } from './oidc-verifier.js'; +import { type GroupsScopesMap, computeOidcScopes } from './scopes.js'; export interface ApiTokenRecord { token: string; @@ -17,17 +36,36 @@ export interface ApiTokenRecord { scopes: string[]; } -export interface AuthContext { - tokenName: string; - scopes: ReadonlySet; +export type AuthSource = 'service-token' | 'oidc-jwt' | 'oidc-cookie'; + +export interface AuthenticatedUser { + source: AuthSource; + /** Pour service tokens : nom logique du token. */ + tokenId?: string; + /** Pour OIDC : email verifie. */ + email?: string; + /** Pour OIDC : sub claim (id stable Authentik). */ + sub?: string; + /** Si lookup PersonneRepo a reussi. */ + personneId?: number; + /** Roles formation-hub deduits via Personne (vide pour service tokens). */ + roles: Role[]; + /** Groups Authentik bruts (vide pour service tokens). */ + groups: string[]; + /** Scopes effectifs : union (groups->scopes) + (roles->scopes) + token.scopes. */ + scopes: string[]; } -/** Hono context variable map — augmente sur l'app pour acces type-safe. */ +/** Hono context variables — `auth` reste pour compat ; `user` est la nouvelle source d'identite. */ export type AuthVariables = { - auth: AuthContext; + auth: { tokenName: string; scopes: ReadonlySet }; + user: AuthenticatedUser; }; -/** Parse `BRIDGE_API_TOKENS` (JSON). Retourne map token → record. */ +// --------------------------------------------------------------------------- +// Service tokens (Bloc 3 inchange — JSON parsing tolere ApiKey/Bearer) +// --------------------------------------------------------------------------- + export function parseTokens(raw: string | undefined): Map { const map = new Map(); if (!raw || raw.trim().length === 0) return map; @@ -56,54 +94,232 @@ export function parseTokens(raw: string | undefined): Map, required: string): boolean { if (owned.has('admin:*')) return true; - return owned.has(required); + if (owned.has(required)) return true; + // Wildcard suffix (`prefix:*` -> couvre `prefix:foo`, `prefix:bar`) + const colonIdx = required.indexOf(':'); + if (colonIdx > 0) { + const prefixWildcard = `${required.slice(0, colonIdx)}:*`; + if (owned.has(prefixWildcard)) return true; + } + return false; } /** - * Factory middleware : exige un scope precis. - * Le middleware d'auth global doit avoir tourne avant pour peupler `c.var.auth`. + * Factory middleware : exige un scope precis. Le middleware d'auth global doit + * avoir tourne avant pour peupler `c.var.user` et `c.var.auth`. */ export function requireScope(scope: string): MiddlewareHandler<{ Variables: AuthVariables }> { return async (c, next) => { - const auth = c.get('auth'); - if (!auth) { + const user = c.get('user'); + if (!user) { throw errors.authRequired(); } - if (!hasScope(auth.scopes, scope)) { + const owned = new Set(user.scopes); + if (!hasScope(owned, scope)) { throw errors.forbidden(scope); } await next(); }; } -/** - * Middleware d'auth global. Lit `Authorization: Bearer brg_*`, peuple `c.var.auth`. - * Pas d'enforcement de scope ici — c'est le job de `requireScope`. - */ +// --------------------------------------------------------------------------- +// Personne lookup avec cache Redis (sha256 email pour eviter PII en clair) +// --------------------------------------------------------------------------- + +export interface PersonneByEmailCache { + get: (key: string) => Promise; + set: (key: string, value: T, ttlSeconds?: number) => Promise; +} + +export interface PersonneFinder { + findByEmail: (email: string) => Promise; +} + +interface CachedPersonne { + id: number; + roles: Role[]; +} + +const PERSONNE_CACHE_TTL = 60; + +function hashEmail(email: string): string { + return createHash('sha256').update(email.trim().toLowerCase()).digest('hex'); +} + +export async function lookupPersonneByEmail( + email: string, + finder: PersonneFinder, + cache: PersonneByEmailCache, + logger: Logger, +): Promise { + const key = `bridge:auth:personne-by-email:${hashEmail(email)}`; + try { + const hit = await cache.get(key); + if (hit) { + if ('miss' in hit) return null; + return hit; + } + } catch (err) { + logger.warn({ err: (err as Error).message }, 'cache get failed, falling through to repo'); + } + const personne = await finder.findByEmail(email); + if (!personne) { + // Cache aussi le miss pour eviter de marteler Baserow. + try { + await cache.set(key, { miss: true }, PERSONNE_CACHE_TTL); + } catch { + /* cache miss-write best effort */ + } + return null; + } + const value: CachedPersonne = { + id: personne.id, + roles: Array.from(personne.roles), + }; + try { + await cache.set(key, value, PERSONNE_CACHE_TTL); + } catch (err) { + logger.warn({ err: (err as Error).message }, 'cache set failed (non-blocking)'); + } + return value; +} + +// --------------------------------------------------------------------------- +// Middleware principal +// --------------------------------------------------------------------------- + +export interface AuthMiddlewareOptions { + tokens: ReadonlyMap; + /** Si null, le mode OIDC est desactive : tout JWT envoye -> 401. */ + oidc: OidcVerifier | null; + /** Map groups Authentik -> scopes. */ + groupsScopesMap: GroupsScopesMap; + /** Si true et email orphelin (pas de Personne) -> 403. Sinon -> autorise avec scopes des groups uniquement. */ + strictMapping: boolean; + /** Cache Redis (peut etre RedisCache ou n'importe quel impl compatible). */ + cache: PersonneByEmailCache; + finder: PersonneFinder; + logger: Logger; +} + +const SERVICE_TOKEN_PREFIX = 'brg_'; + +interface ParsedHeader { + scheme: 'apikey' | 'bearer' | null; + value: string | null; +} + +function parseAuthHeader(header: string | undefined): ParsedHeader { + if (!header) return { scheme: null, value: null }; + const apikey = header.match(/^ApiKey\s+(.+)$/i); + if (apikey) return { scheme: 'apikey', value: apikey[1].trim() }; + const bearer = header.match(/^Bearer\s+(.+)$/i); + if (bearer) return { scheme: 'bearer', value: bearer[1].trim() }; + return { scheme: null, value: null }; +} + export function authMiddleware( - tokens: ReadonlyMap, + opts: AuthMiddlewareOptions, ): MiddlewareHandler<{ Variables: AuthVariables }> { + const { tokens, oidc, groupsScopesMap, strictMapping, cache, finder, logger } = opts; + return async (c, next) => { - const header = c.req.header('Authorization'); - if (!header) { - throw errors.authRequired(); + const headerRaw = c.req.header('Authorization'); + const parsed = parseAuthHeader(headerRaw); + const cookieToken = getCookie(c, 'authToken'); + + // --- Service token : ApiKey/Bearer + valeur commence par brg_ ---------- + if (parsed.value?.startsWith(SERVICE_TOKEN_PREFIX)) { + const record = tokens.get(parsed.value); + if (!record) { + throw errors.authInvalid(); + } + const scopes = record.scopes; + const user: AuthenticatedUser = { + source: 'service-token', + tokenId: record.name, + roles: [], + groups: [], + scopes, + }; + c.set('user', user); + c.set('auth', { tokenName: record.name, scopes: new Set(scopes) }); + await next(); + return; } - const match = header.match(/^Bearer\s+(.+)$/); - if (!match) { + + // --- Header avec format invalide (ni ApiKey, ni Bearer) ---------------- + if (headerRaw && parsed.scheme === null) { throw errors.authInvalid(); } - const token = match[1].trim(); - const record = tokens.get(token); - if (!record) { - throw errors.authInvalid(); + + // --- OIDC JWT (Bearer non-brg_ ou cookie) ------------------------------ + let jwt: string | null = null; + let source: AuthSource | null = null; + if ( + parsed.scheme === 'bearer' && + parsed.value && + !parsed.value.startsWith(SERVICE_TOKEN_PREFIX) + ) { + jwt = parsed.value; + source = 'oidc-jwt'; + } else if (cookieToken) { + jwt = cookieToken; + source = 'oidc-cookie'; } - c.set('auth', { tokenName: record.name, scopes: new Set(record.scopes) }); - await next(); + + if (jwt) { + if (!oidc) { + // Mode OIDC desactive : tout JWT envoye est rejete plutot que silencieusement + // ignore. Evite les surprises ("pourquoi mon token JWT est ignore ?"). + logger.warn({ source }, 'JWT received but OIDC mode disabled'); + throw errors.authInvalid(); + } + const verified = await oidc.verify(jwt); + const email = extractEmail(verified.payload); + const sub = typeof verified.payload.sub === 'string' ? verified.payload.sub : undefined; + const groups = extractGroups(verified.payload); + + let personneId: number | undefined; + let roles: Role[] = []; + if (email) { + const found = await lookupPersonneByEmail(email, finder, cache, logger); + if (found) { + personneId = found.id; + roles = found.roles; + } else if (strictMapping) { + logger.warn({ email, sub }, 'OIDC user not found in Personne (strict mode)'); + throw errors.forbiddenIdentity('Aucune Personne formation-hub liee a cet email', { + email, + }); + } + } else if (strictMapping) { + throw errors.forbiddenIdentity('JWT sans email exploitable', {}); + } + + const scopes = computeOidcScopes(groups, new Set(roles), groupsScopesMap); + const user: AuthenticatedUser = { + source: source ?? 'oidc-jwt', + email: email ?? undefined, + sub, + personneId, + roles, + groups, + scopes, + }; + c.set('user', user); + // Compat : c.var.auth pour les rares endpoints Bloc 3 qui le lisent encore. + c.set('auth', { + tokenName: email ?? sub ?? 'oidc-anonymous', + scopes: new Set(scopes), + }); + await next(); + return; + } + + throw errors.authRequired(); }; } diff --git a/bridge/src/middleware/oidc-verifier.ts b/bridge/src/middleware/oidc-verifier.ts new file mode 100644 index 0000000..94520a7 --- /dev/null +++ b/bridge/src/middleware/oidc-verifier.ts @@ -0,0 +1,94 @@ +/** + * Verification JWT OIDC Authentik via jose + JWKS remote. + * + * Pourquoi `jose` plutot que `jsonwebtoken` : + * - support natif `createRemoteJWKSet` (cache + rotation des cles publiques) + * - import ESM, types stricts, pas de polyfill `Buffer` cote runtime + * - alignement avec les recos OpenID Foundation pour Node 18+. + * + * On accepte uniquement RS256/RS384/RS512 — pas de HS* car la cle est publique + * (JWKS), et autoriser HS* expose au confused-deputy si un attaquant pousse un + * JWKS qui contient un kty=oct. + */ + +import { + type JWTPayload, + type JWTVerifyGetKey, + type JWTVerifyResult, + createRemoteJWKSet, + jwtVerify, +} from 'jose'; +import type { Logger } from 'pino'; +import { errors } from '../lib/errors.js'; + +export interface OidcVerifierOptions { + issuer: string; + jwksUri: string; + audience: string; + logger: Logger; + /** Pour tests : injecter un getKey custom (ex: locale au lieu de remote). */ + getKey?: JWTVerifyGetKey; + /** TTL cache JWKS en ms. Defaut 10min. */ + jwksCacheMaxAgeMs?: number; +} + +export type VerifiedJwt = JWTVerifyResult & { payload: JWTPayload }; + +const ALGORITHMS = ['RS256', 'RS384', 'RS512'] as const; + +export class OidcVerifier { + private readonly getKey: JWTVerifyGetKey; + private readonly issuer: string; + private readonly audience: string; + private readonly logger: Logger; + + constructor(opts: OidcVerifierOptions) { + this.issuer = opts.issuer; + this.audience = opts.audience; + this.logger = opts.logger.child({ middleware: 'oidc-verifier' }); + this.getKey = + opts.getKey ?? + createRemoteJWKSet(new URL(opts.jwksUri), { + cacheMaxAge: opts.jwksCacheMaxAgeMs ?? 600_000, + }); + } + + /** + * Verifie un JWT. Throw `errors.authInvalid()` si invalide (signature, expired, + * issuer mismatch, audience mismatch, alg pas accepte). + */ + async verify(token: string): Promise { + try { + return await jwtVerify(token, this.getKey, { + issuer: this.issuer, + audience: this.audience, + algorithms: [...ALGORITHMS], + }); + } catch (err) { + const code = (err as { code?: string }).code ?? 'JWT_VERIFY_FAILED'; + this.logger.warn({ code, err: (err as Error).message }, 'JWT verification failed'); + throw errors.authInvalid(); + } + } +} + +/** + * Extrait l'email du payload JWT en privilegiant `email` (Authentik le set par defaut), + * fallback sur `preferred_username` si format email. Retourne null sinon. + */ +export function extractEmail(payload: JWTPayload): string | null { + const email = payload.email; + if (typeof email === 'string' && email.includes('@')) return email.trim().toLowerCase(); + const preferred = (payload as Record).preferred_username; + if (typeof preferred === 'string' && preferred.includes('@')) { + return preferred.trim().toLowerCase(); + } + return null; +} + +/** Extrait les groupes Authentik. Authentik les met dans `groups` (array de strings). */ +export function extractGroups(payload: JWTPayload): string[] { + const raw = (payload as Record).groups; + if (!Array.isArray(raw)) return []; + return raw.filter((g): g is string => typeof g === 'string' && g.length > 0); +} diff --git a/bridge/src/middleware/scopes.ts b/bridge/src/middleware/scopes.ts new file mode 100644 index 0000000..9513c7d --- /dev/null +++ b/bridge/src/middleware/scopes.ts @@ -0,0 +1,72 @@ +/** + * Mapping groupes Authentik + roles formation-hub vers scopes bridge. + * + * Sources de scopes pour un utilisateur OIDC : + * 1. groups Authentik (mappes via `AUTH_GROUPS_SCOPES_MAP` JSON) + * 2. roles formation-hub portes par la Personne (mappes via DEFAULT_ROLE_SCOPES) + * 3. union des deux + * + * Le default role-scope mapping est volontairement conservateur : seul `admin` + * obtient `admin:*`. Les autres roles reçoivent le strict necessaire pour leur travail. + */ + +import type { Role } from '../domain/types.js'; + +export type GroupsScopesMap = Record; + +/** + * Defaut role -> scopes si rien n'est configure dans `AUTH_GROUPS_SCOPES_MAP`. + * Mantra IA-1 (Trust But Verify) : pas de wildcard sauf admin explicite. + */ +export const DEFAULT_ROLE_SCOPES: Record = { + admin: ['admin:*'], + direction: ['read:personnes', 'read:formations', 'read:projets'], + formateur: ['read:personnes', 'read:formations', 'write:attributions'], + developpeur: ['read:personnes', 'read:projets', 'write:interventions'], + support: ['read:personnes', 'read:formations', 'read:projets'], +}; + +export function parseGroupsScopesMap(raw: string | undefined): GroupsScopesMap { + if (!raw || raw.trim().length === 0) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('AUTH_GROUPS_SCOPES_MAP: JSON invalide'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('AUTH_GROUPS_SCOPES_MAP: doit etre un objet { group: [scopes] }'); + } + const out: GroupsScopesMap = {}; + for (const [group, scopes] of Object.entries(parsed as Record)) { + if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== 'string')) { + throw new Error(`AUTH_GROUPS_SCOPES_MAP[${group}]: doit etre un tableau de strings`); + } + out[group] = scopes as string[]; + } + return out; +} + +/** + * Calcule l'union des scopes pour un user OIDC. + * - groups Authentik : si pas de mapping fourni, fallback sur le nom de groupe + * qui matche un Role connu (ex: `formation-hub-formateurs` -> formateur). + * - roles formation-hub : DEFAULT_ROLE_SCOPES. + */ +export function computeOidcScopes( + groups: string[], + roles: ReadonlySet, + groupsMap: GroupsScopesMap, +): string[] { + const scopes = new Set(); + for (const g of groups) { + const direct = groupsMap[g]; + if (direct) { + for (const s of direct) scopes.add(s); + } + } + for (const role of roles) { + for (const s of DEFAULT_ROLE_SCOPES[role] ?? []) scopes.add(s); + } + return Array.from(scopes).sort(); +} diff --git a/bridge/src/repos/baserow-repo.ts b/bridge/src/repos/baserow-repo.ts index 27c4d91..92d60aa 100644 --- a/bridge/src/repos/baserow-repo.ts +++ b/bridge/src/repos/baserow-repo.ts @@ -203,6 +203,37 @@ abstract class BaseRepo { // --------------------------------------------------------------------------- export class PersonneRepo extends BaseRepo { + /** + * Recherche par email exact. Renvoie null si aucune Personne ne match + * (au lieu de NOT_FOUND) — utile pour l'auth OIDC ou un email orphelin + * n'est pas une erreur protocolaire mais un cas metier (politique strict/permissive). + */ + async findByEmail(email: string): Promise { + const normalized = email.trim().toLowerCase(); + if (normalized.length === 0) return null; + // Baserow filter __contains via `filter` ou `search` full-text. On utilise search + // (suffisant pour un email puisqu'il est unique) puis on filtre exact post-fetch + // pour eviter qu'un email substring matche un autre. + const res = await this.client.listRows(this.tableId, { + search: normalized, + size: 10, + }); + const exact = res.results.find((row) => { + const raw = row.personne_email; + return typeof raw === 'string' && raw.trim().toLowerCase() === normalized; + }); + if (!exact) return null; + try { + return this.toDomain(exact); + } catch (err) { + this.logger.warn( + { email: normalized, err: err instanceof Error ? err.message : String(err) }, + 'findByEmail: row malformee, ignoree', + ); + return null; + } + } + protected toDomain(row: BaserowRow): Personne { const splitFormation = readNumber(row.personne_split_formation_pct); const splitAgence = readNumber(row.personne_split_agence_pct); diff --git a/bridge/tests/helpers/test-app.ts b/bridge/tests/helpers/test-app.ts index 8894e35..7cc6cea 100644 --- a/bridge/tests/helpers/test-app.ts +++ b/bridge/tests/helpers/test-app.ts @@ -7,6 +7,7 @@ import { Hono } from 'hono'; import { logger as honoLogger } from 'hono/logger'; import type { BaserowClient } from '../../src/adapters/baserow-client.js'; import type { RedisCache } from '../../src/adapters/redis-cache.js'; +import type { Personne } from '../../src/domain/personne.js'; import type { Container } from '../../src/lib/container.js'; import { setContainer } from '../../src/lib/container.js'; import { logger } from '../../src/lib/logger.js'; @@ -80,18 +81,29 @@ export function installTestContainer(over: TestContainerOverrides): Container { baserowWebhookSecret: 'fake_secret_at_least_16_chars', docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars', bridgeApiTokens: undefined, + authStrictMapping: true, }, baserow: fakeBaserow, redis: fakeRedis, repos: over.repos, tokens: tokensMap, tableIds: FAKE_TABLE_IDS, + oidc: null, + groupsScopesMap: {}, logger, }; setContainer(container); return container; } +const NOOP_CACHE = { + get: async (_key: string): Promise => null, + set: async (_key: string, _value: T, _ttl?: number): Promise => {}, +}; +const NOOP_FINDER = { + findByEmail: async (_email: string): Promise => null, +}; + export function resetTestContainer(): void { setContainer(null); } @@ -106,7 +118,18 @@ export function buildTestApp(container: Container): Hono<{ Variables: AuthVariab app.route('/api/webhooks', webhooksRoutes); const v1 = new Hono<{ Variables: AuthVariables }>(); - v1.use('*', authMiddleware(container.tokens)); + v1.use( + '*', + authMiddleware({ + tokens: container.tokens, + oidc: container.oidc, + groupsScopesMap: container.groupsScopesMap, + strictMapping: container.config.authStrictMapping, + cache: NOOP_CACHE, + finder: NOOP_FINDER, + logger, + }), + ); v1.route('/personnes', personnesRoutes); v1.route('/formations', formationsRoutes); v1.route('/projets', projetsRoutes); diff --git a/bridge/tests/middleware/auth.test.ts b/bridge/tests/middleware/auth.test.ts index 1666ca3..d6259b9 100644 --- a/bridge/tests/middleware/auth.test.ts +++ b/bridge/tests/middleware/auth.test.ts @@ -1,5 +1,19 @@ +/** + * 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 { describe, expect, it } from 'vitest'; +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, @@ -9,20 +23,185 @@ import { requireScope, } from '../../src/middleware/auth.js'; import { errorHandler } from '../../src/middleware/error-handler.js'; +import { OidcVerifier } from '../../src/middleware/oidc-verifier.js'; -function buildApp(tokens: ApiTokenRecord[]): Hono<{ Variables: AuthVariables }> { +// --------------------------------------------------------------------------- +// 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(map)); - app.get('/protected/read', requireScope('read:personnes'), (c) => - c.json({ ok: true, scopes: Array.from(c.get('auth').scopes) }), + app.use( + '/protected/*', + authMiddleware({ + tokens: map, + oidc: verifier, + groupsScopesMap: opts.groupsScopesMap ?? {}, + strictMapping: opts.strictMapping ?? true, + cache, + finder, + logger, + }), ); - app.get('/protected/admin', requireScope('admin:something'), (c) => c.json({ ok: true })); - return app; + + 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( @@ -58,36 +237,43 @@ describe('hasScope', () => { }); it('admin:* couvre tout', () => { expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true); - expect(hasScope(new Set(['admin:*']), 'write:something')).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 — 5 cas', () => { +describe('auth middleware — service tokens (mode local)', () => { const tokens: ApiTokenRecord[] = [ - { token: 'brg_valid', name: 'demo', scopes: ['read:personnes'] }, + { token: 'brg_valid', name: 'demo', scopes: ['formation:read', 'admin:write'] }, ]; - it('401 si pas de header', async () => { - const app = buildApp(tokens); - const res = await app.request('/protected/read'); - expect(res.status).toBe(401); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('AUTH_REQUIRED'); - }); - - it('401 si format wrong (pas Bearer)', async () => { - const app = buildApp(tokens); - const res = await app.request('/protected/read', { - headers: { Authorization: 'Token brg_valid' }, + 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(401); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('AUTH_INVALID'); + 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('401 si token inconnu', async () => { - const app = buildApp(tokens); - const res = await app.request('/protected/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); @@ -95,24 +281,260 @@ describe('auth middleware — 5 cas', () => { expect(body.error.code).toBe('AUTH_INVALID'); }); - it('403 si scope manquant', async () => { - const app = buildApp(tokens); - const res = await app.request('/protected/admin', { - headers: { Authorization: 'Bearer brg_valid' }, + 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('200 si token + scope OK', async () => { - const app = buildApp(tokens); - const res = await app.request('/protected/read', { - headers: { Authorization: 'Bearer brg_valid' }, + 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(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean; scopes: string[] }; - expect(body.ok).toBe(true); - expect(body.scopes).toContain('read:personnes'); + 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); }); }); diff --git a/bridge/tests/middleware/scopes.test.ts b/bridge/tests/middleware/scopes.test.ts new file mode 100644 index 0000000..179ff6b --- /dev/null +++ b/bridge/tests/middleware/scopes.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_ROLE_SCOPES, + computeOidcScopes, + parseGroupsScopesMap, +} from '../../src/middleware/scopes.js'; + +describe('parseGroupsScopesMap', () => { + it('retourne {} si vide', () => { + expect(parseGroupsScopesMap(undefined)).toEqual({}); + expect(parseGroupsScopesMap('')).toEqual({}); + }); + + it('parse un mapping valide', () => { + const map = parseGroupsScopesMap('{"g1":["a","b"],"g2":["c"]}'); + expect(map).toEqual({ g1: ['a', 'b'], g2: ['c'] }); + }); + + it('throw si JSON invalide', () => { + expect(() => parseGroupsScopesMap('{')).toThrow(/JSON/); + }); + + it('throw si pas un objet', () => { + expect(() => parseGroupsScopesMap('[1,2]')).toThrow(/objet/); + expect(() => parseGroupsScopesMap('"x"')).toThrow(/objet/); + }); + + it('throw si valeur pas array of strings', () => { + expect(() => parseGroupsScopesMap('{"g":[1]}')).toThrow(); + expect(() => parseGroupsScopesMap('{"g":"x"}')).toThrow(); + }); +}); + +describe('computeOidcScopes', () => { + it('union groups + roles + dedup', () => { + const scopes = computeOidcScopes(['formation-hub-formateurs'], new Set(['formateur']), { + 'formation-hub-formateurs': ['formation:read', 'admin:custom'], + }); + expect(scopes).toContain('formation:read'); + expect(scopes).toContain('admin:custom'); + // Vient du DEFAULT_ROLE_SCOPES.formateur + expect(scopes).toContain('write:attributions'); + }); + + it("group inconnu ignore (pas d'erreur)", () => { + const scopes = computeOidcScopes(['unknown-group'], new Set(), {}); + expect(scopes).toEqual([]); + }); + + it('default mapping admin role -> admin:*', () => { + const scopes = computeOidcScopes([], new Set(['admin']), {}); + expect(scopes).toContain('admin:*'); + }); + + it('aucun group + aucun role = scopes vides', () => { + expect(computeOidcScopes([], new Set(), {})).toEqual([]); + }); + + it('DEFAULT_ROLE_SCOPES couvre les 5 roles', () => { + expect(Object.keys(DEFAULT_ROLE_SCOPES)).toHaveLength(5); + }); +}); diff --git a/bridge/tests/repos/baserow-repo.test.ts b/bridge/tests/repos/baserow-repo.test.ts index b6a48a5..1c4093e 100644 --- a/bridge/tests/repos/baserow-repo.test.ts +++ b/bridge/tests/repos/baserow-repo.test.ts @@ -111,6 +111,90 @@ describe('PersonneRepo', () => { }); await expect(repo.get(999)).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); + + describe('findByEmail', () => { + function makeRow(email: string, id = 1): BaserowRow { + return { + id, + order: '1', + personne_nom: 'Doe', + personne_prenom: 'Jane', + personne_email: email, + personne_capacite_annuelle: '1000', + personne_split_formation_pct: 50, + personne_split_agence_pct: 50, + personne_roles: [{ id: 1, value: 'formateur', color: 'blue' }], + personne_statut: { id: 2, value: 'actif', color: 'green' }, + }; + } + + it('retourne la Personne sur match exact', async () => { + const repo = new PersonneRepo({ + client: fakeClient({ 1: [makeRow('jane@acadenice.fr', 7)] }), + tableId: 1, + entityName: 'Personne', + logger: log, + }); + const found = await repo.findByEmail('jane@acadenice.fr'); + expect(found?.id).toBe(7); + }); + + it('insensitive a la casse + trim', async () => { + const repo = new PersonneRepo({ + client: fakeClient({ 1: [makeRow('jane@acadenice.fr', 7)] }), + tableId: 1, + entityName: 'Personne', + logger: log, + }); + const found = await repo.findByEmail(' Jane@AcadeNice.fr '); + expect(found?.id).toBe(7); + }); + + it('retourne null si email vide', async () => { + const repo = new PersonneRepo({ + client: fakeClient({ 1: [] }), + tableId: 1, + entityName: 'Personne', + logger: log, + }); + expect(await repo.findByEmail('')).toBeNull(); + expect(await repo.findByEmail(' ')).toBeNull(); + }); + + it('retourne null si aucune row ne match exact (substring rejet)', async () => { + const repo = new PersonneRepo({ + client: fakeClient({ 1: [makeRow('john.jane@acadenice.fr', 7)] }), + tableId: 1, + entityName: 'Personne', + logger: log, + }); + const found = await repo.findByEmail('jane@acadenice.fr'); + expect(found).toBeNull(); + }); + + it('retourne null si row corrompue (split != 100)', async () => { + const corrupt: BaserowRow = { + id: 1, + order: '1', + personne_nom: 'X', + personne_prenom: 'Y', + personne_email: 'corrupt@test.fr', + personne_capacite_annuelle: '1000', + personne_split_formation_pct: 60, + personne_split_agence_pct: 60, // total = 120 -> domain throw + personne_roles: [], + personne_statut: 'actif', + }; + const repo = new PersonneRepo({ + client: fakeClient({ 1: [corrupt] }), + tableId: 1, + entityName: 'Personne', + logger: log, + }); + const found = await repo.findByEmail('corrupt@test.fr'); + expect(found).toBeNull(); + }); + }); }); describe('FormationRepo', () => { diff --git a/bridge/vitest.config.ts b/bridge/vitest.config.ts index 9ff7d55..67092b3 100644 --- a/bridge/vitest.config.ts +++ b/bridge/vitest.config.ts @@ -30,6 +30,13 @@ export default defineConfig({ branches: 80, statements: 80, }, + // Bloc 4 : auth middleware OIDC-ready. Seuil >= 85% lines+branches. + 'src/middleware/auth.ts': { + lines: 85, + functions: 85, + branches: 85, + statements: 85, + }, }, }, passWithNoTests: true,