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
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:
parent
8b42cbc787
commit
571f5c3426
17 changed files with 1202 additions and 87 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
bridge/package-lock.json
generated
10
bridge/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
94
bridge/src/middleware/oidc-verifier.ts
Normal file
94
bridge/src/middleware/oidc-verifier.ts
Normal 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);
|
||||
}
|
||||
72
bridge/src/middleware/scopes.ts
Normal file
72
bridge/src/middleware/scopes.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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('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<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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
62
bridge/tests/middleware/scopes.test.ts
Normal file
62
bridge/tests/middleware/scopes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue