feat(auth): Bloc 4 — middleware OIDC-ready avec dual mode service-token + Authentik JWT
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
This commit is contained in:
Corentin JOGUET 2026-05-07 21:17:56 +02:00
parent 8b42cbc787
commit 571f5c3426
17 changed files with 1202 additions and 87 deletions

View file

@ -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 <jwt>` OU cookie `authToken=<jwt>` (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:<sha256(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.

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -43,9 +43,20 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
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);

View file

@ -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<typeof ConfigSchema>;
@ -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<Config, 'authentikIssuer' | 'authentikJwksUri' | 'authentikAudience'>,
): boolean {
return Boolean(c.authentikIssuer && c.authentikJwksUri && c.authentikAudience);
}

View file

@ -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<string, ApiTokenRecord>;
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<Container> {
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<Container> {
repos,
tokens,
tableIds,
oidc,
groupsScopesMap,
logger: rootLogger,
};
setContainer(container);

View file

@ -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<string, unknown>) =>
new BridgeError('FORBIDDEN', 403, reason, details),
notFound: (entity: string, id: string | number) =>
new BridgeError('NOT_FOUND', 404, `${entity} introuvable`, { entity, id }),
validation: (issues: unknown) =>

View file

@ -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 <jwt>` 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 <jwt>` (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<string>;
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<string> };
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<string, ApiTokenRecord> {
const map = new Map<string, ApiTokenRecord>();
if (!raw || raw.trim().length === 0) return map;
@ -56,54 +94,232 @@ export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord
return map;
}
/**
* Verifie si un set de scopes possedes couvre la demande.
* `admin:*` couvre tout. Match exact sinon.
*/
/** Match scope avec wildcard. `admin:*` couvre tout, `read:*` couvre `read:personnes`, etc. */
export function hasScope(owned: ReadonlySet<string>, 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: <T>(key: string) => Promise<T | null>;
set: <T>(key: string, value: T, ttlSeconds?: number) => Promise<void>;
}
export interface PersonneFinder {
findByEmail: (email: string) => Promise<Personne | null>;
}
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<CachedPersonne | null> {
const key = `bridge:auth:personne-by-email:${hashEmail(email)}`;
try {
const hit = await cache.get<CachedPersonne | { miss: true }>(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<string, ApiTokenRecord>;
/** 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<string, ApiTokenRecord>,
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 match = header.match(/^Bearer\s+(.+)$/);
if (!match) {
throw errors.authInvalid();
}
const token = match[1].trim();
const record = tokens.get(token);
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();
}
c.set('auth', { tokenName: record.name, scopes: new Set(record.scopes) });
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;
}
// --- Header avec format invalide (ni ApiKey, ni Bearer) ----------------
if (headerRaw && parsed.scheme === null) {
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';
}
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();
};
}

View file

@ -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<VerifiedJwt> {
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<string, unknown>).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<string, unknown>).groups;
if (!Array.isArray(raw)) return [];
return raw.filter((g): g is string => typeof g === 'string' && g.length > 0);
}

View file

@ -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<string, string[]>;
/**
* 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<Role, string[]> = {
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<string, unknown>)) {
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<Role>,
groupsMap: GroupsScopesMap,
): string[] {
const scopes = new Set<string>();
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();
}

View file

@ -203,6 +203,37 @@ abstract class BaseRepo<TDomain> {
// ---------------------------------------------------------------------------
export class PersonneRepo extends BaseRepo<Personne> {
/**
* 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<Personne | null> {
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);

View file

@ -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 <T>(_key: string): Promise<T | null> => null,
set: async <T>(_key: string, _value: T, _ttl?: number): Promise<void> => {},
};
const NOOP_FINDER = {
findByEmail: async (_email: string): Promise<Personne | null> => 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);

View file

@ -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<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(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('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('401 si format wrong (pas Bearer)', async () => {
const app = buildApp(tokens);
const res = await app.request('/protected/read', {
headers: { Authorization: 'Token brg_valid' },
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(401);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('AUTH_INVALID');
expect(res.status).toBe(200);
});
it('401 si token inconnu', async () => {
const app = buildApp(tokens);
const res = await app.request('/protected/read', {
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<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('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);
});
});

View file

@ -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);
});
});

View file

@ -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', () => {

View file

@ -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,