From a79c51e6f2efe04a2fa042c68c18582d07e15b12 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 7 May 2026 23:02:01 +0200 Subject: [PATCH] feat(auth): R2.3b bridge accepte JWT HMAC DocAdenice via DOCMOST_APP_SECRET --- .../fast-app/formation-hub/SESSION-RESUME.md | 41 +++ bridge/.env.example | 10 + bridge/src/index.ts | 1 + bridge/src/lib/config.ts | 18 ++ bridge/src/lib/container.ts | 24 +- bridge/src/middleware/auth.ts | 178 ++++++++--- bridge/src/middleware/docmost-jwt-verifier.ts | 173 +++++++++++ bridge/tests/middleware/auth.test.ts | 265 ++++++++++++++++ .../middleware/docmost-jwt-verifier.test.ts | 289 ++++++++++++++++++ bridge/vitest.config.ts | 7 + 10 files changed, 961 insertions(+), 45 deletions(-) create mode 100644 bridge/src/middleware/docmost-jwt-verifier.ts create mode 100644 bridge/tests/middleware/docmost-jwt-verifier.test.ts diff --git a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md index b824f53..1b97453 100644 --- a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md +++ b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md @@ -1,3 +1,44 @@ +# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 R2.3b) + +## CHANGELOG R2.3b — Bridge accepte JWT HMAC DocAdenice (mode local sans Authentik) + +Date : 2026-05-07 +Commit local (a pusher manuellement) : voir `git log -1` dans `bridge/`. + +**Pourquoi** : DocAdenice signe ses JWT en HS256 avec `appSecret`. En local sans +Authentik branche, le frontend DocAdenice qui call le bridge directement aurait +echoue (le bridge ne savait valider que `brg_*` et JWT RS256 Authentik). R2.3b +ajoute un troisieme mode au middleware : valider les JWT HS256/384/512 signes +par DocAdenice via `DOCMOST_APP_SECRET`. + +**Fichiers crees** : +- `bridge/src/middleware/docmost-jwt-verifier.ts` (verifier HMAC + helpers `decodeJwtAlg` + `extractDocmostPermissions`) +- `bridge/tests/middleware/docmost-jwt-verifier.test.ts` (28 tests unitaires) + +**Fichiers modifies** : +- `bridge/src/lib/config.ts` : 3 nouvelles vars (`docmostAppSecret`, `docmostJwtIssuer` default "Docmost", `docmostJwtAudience`) + helper `isDocmostJwtEnabled()` +- `bridge/src/lib/container.ts` : champ `docmostJwt: DocmostJwtVerifier | null`, init si secret >= 32 chars +- `bridge/src/middleware/auth.ts` : routing par algo JWT (decode header non verifie -> RSA -> OIDC, HMAC -> DocAdenice). Sources d'auth ajoutees : `docmost-jwt`, `docmost-cookie`. Refactor en helpers internes pour separer la logique attach par mode. +- `bridge/src/index.ts` : injecte `ctn.docmostJwt` dans l'app +- `bridge/.env.example` : section commentee `DOCMOST_APP_SECRET` / `DOCMOST_JWT_ISSUER` / `DOCMOST_JWT_AUDIENCE` +- `bridge/vitest.config.ts` : threshold >= 85% sur `docmost-jwt-verifier.ts` +- `bridge/tests/middleware/auth.test.ts` : +14 tests (DocAdenice mode + coexistence Authentik/DocAdenice + algo none rejected) + +**Quality gates** : +- typecheck : OK +- lint : OK +- tests : 292/292 verts (was 250/250 — +42 tests) +- coverage : `docmost-jwt-verifier.ts` 100% lines/funcs/97.87% branches, `auth.ts` 96.35% lines/93.61% branches + +**Choix techniques** : +- `decodeJwtAlg` : decode header non verifie pour router vers le bon mode. Si JWT non decodable -> AUTH_INVALID immediat. Si algo n'a pas de mode actif -> AUTH_INVALID (pas de fallback silencieux). +- DocAdenice JWT : pas de mapping `groupsScopesMap` — le claim `acadenice_permissions[]` (R2.1) est la source de verite directe (DocAdenice resout deja tout via son RBAC). `scopes = permissions = acadenice_permissions[]`. +- Constant-time : `jose.jwtVerify` utilise `node:crypto.timingSafeEqual` pour comparaison HMAC. +- Algo none / ES* / EdDSA explicitement rejetes (`AUTH_INVALID`) — seuls RS* (mode 2) et HS* (mode 3) routent quelque part. +- Validation claims requis dans le verifier : `sub`, `workspaceId`, `type` doivent etre presents et non vides. + +--- + # SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 R1 refactor) ## Vision — DocAdenice = Notion-like generique diff --git a/bridge/.env.example b/bridge/.env.example index 44a0614..8acf55e 100644 --- a/bridge/.env.example +++ b/bridge/.env.example @@ -40,6 +40,16 @@ BRIDGE_API_TOKENS= # R1 generique : le bridge lit aussi le claim JWT `acadenice_permissions[]` # qui alimente directement les scopes (alimente cote DocAdenice par le RBAC R2). +# JWT HMAC DocAdenice (Docmost fork) — mode local sans Authentik (R2.3b) +# Le bridge accepte les JWT HS256/384/512 signes par DocAdenice avec son APP_SECRET +# (le meme secret que `docmost.appSecret`). Permet au frontend Docmost d'appeler +# le bridge directement avec son cookie/Bearer Docmost natif, sans IdP OIDC. +# Laisse vide en prod si Authentik OIDC est branche — l'utilisateur passe par OIDC. +# Le secret doit faire >= 32 chars (matche les contraintes Docmost). +# DOCMOST_APP_SECRET=must-be-32-chars-or-more-and-match-docmost-app-secret +# DOCMOST_JWT_ISSUER=Docmost +# DOCMOST_JWT_AUDIENCE= + # Rate limiting (Bloc 5) — sliding window Redis sur /api/v1/* # (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense). # Global s'applique sur tous les verbes ; Mutation s'ajoute sur POST/PATCH/PUT/DELETE diff --git a/bridge/src/index.ts b/bridge/src/index.ts index de419d9..3d813cf 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -49,6 +49,7 @@ export async function buildApp(): Promise> { authMiddleware({ tokens: ctn.tokens, oidc: ctn.oidc, + docmostJwt: ctn.docmostJwt, groupsScopesMap: ctn.groupsScopesMap, logger: ctn.logger, }), diff --git a/bridge/src/lib/config.ts b/bridge/src/lib/config.ts index ec0963b..b05c87c 100644 --- a/bridge/src/lib/config.ts +++ b/bridge/src/lib/config.ts @@ -21,6 +21,13 @@ const ConfigSchema = z.object({ authentikAudience: z.string().min(1).optional(), // JSON serialise group->scopes ; parse fait dans le middleware auth. authGroupsScopesMap: z.string().optional(), + // JWT HMAC DocAdenice (Docmost fork) — mode local sans Authentik. + // Si `docmostAppSecret` est set (>= 32 chars), le bridge accepte les JWT HS256/384/512 + // signes par DocAdenice avec son APP_SECRET. Le claim issuer attendu est par defaut + // "Docmost" (cf docmost/apps/server/src/core/auth/token.module.ts). Audience optionnelle. + docmostAppSecret: z.string().min(32).optional(), + docmostJwtIssuer: z.string().min(1).default('Docmost'), + docmostJwtAudience: z.string().min(1).optional(), // Rate limiting (Bloc 5). Global s'applique sur tout /api/v1/* ; mutation s'ajoute // sur POST/PATCH/PUT/DELETE et est volontairement plus strict pour proteger // contre les bursts buggy / scripts mal configures. @@ -49,6 +56,9 @@ export function loadConfig(): Config { authentikJwksUri: process.env.AUTHENTIK_JWKS_URI, authentikAudience: process.env.AUTHENTIK_AUDIENCE, authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP, + docmostAppSecret: process.env.DOCMOST_APP_SECRET, + docmostJwtIssuer: process.env.DOCMOST_JWT_ISSUER, + docmostJwtAudience: process.env.DOCMOST_JWT_AUDIENCE, rateLimitGlobalMax: process.env.RATE_LIMIT_GLOBAL_MAX, rateLimitGlobalWindow: process.env.RATE_LIMIT_GLOBAL_WINDOW, rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX, @@ -71,3 +81,11 @@ export function isOidcEnabled( ): boolean { return Boolean(c.authentikIssuer && c.authentikJwksUri && c.authentikAudience); } + +/** + * True si le mode JWT HMAC DocAdenice est actif. Suffit d'avoir `docmostAppSecret` + * — issuer a un default ("Docmost"), audience est optionnelle. + */ +export function isDocmostJwtEnabled(c: Pick): boolean { + return Boolean(c.docmostAppSecret && c.docmostAppSecret.length >= 32); +} diff --git a/bridge/src/lib/container.ts b/bridge/src/lib/container.ts index 9cfc0bf..250b345 100644 --- a/bridge/src/lib/container.ts +++ b/bridge/src/lib/container.ts @@ -13,6 +13,7 @@ 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 { DocmostJwtVerifier } from '../middleware/docmost-jwt-verifier.js'; import { OidcVerifier } from '../middleware/oidc-verifier.js'; import { type GroupsScopesMap, parseGroupsScopesMap } from '../middleware/scopes.js'; import { BaserowFieldsRepo } from '../repos/baserow-fields-repo.js'; @@ -20,7 +21,7 @@ import { BaserowRowsRepo } from '../repos/baserow-rows-repo.js'; import { BaserowTablesRepo } from '../repos/baserow-tables-repo.js'; import { BaserowViewsRepo } from '../repos/baserow-views-repo.js'; import type { Config } from './config.js'; -import { isOidcEnabled } from './config.js'; +import { isDocmostJwtEnabled, isOidcEnabled } from './config.js'; import { logger as rootLogger } from './logger.js'; export interface RepoSet { @@ -38,6 +39,8 @@ export interface Container { tokens: ReadonlyMap; /** Null si mode OIDC desactive (vars Authentik manquantes). */ oidc: OidcVerifier | null; + /** Null si mode JWT HMAC DocAdenice desactive (`DOCMOST_APP_SECRET` vide). */ + docmostJwt: DocmostJwtVerifier | null; groupsScopesMap: GroupsScopesMap; logger: Logger; } @@ -96,7 +99,23 @@ export async function initContainer(opts: InitOptions): Promise { 'OIDC mode enabled', ); } else { - rootLogger.info('OIDC mode disabled — service tokens only'); + rootLogger.info('OIDC mode disabled'); + } + + let docmostJwt: DocmostJwtVerifier | null = null; + if (isDocmostJwtEnabled(config)) { + docmostJwt = new DocmostJwtVerifier({ + secret: config.docmostAppSecret as string, + issuer: config.docmostJwtIssuer, + audience: config.docmostJwtAudience, + logger: rootLogger, + }); + rootLogger.info( + { issuer: config.docmostJwtIssuer, audience: config.docmostJwtAudience ?? null }, + 'DocAdenice JWT HMAC mode enabled', + ); + } else { + rootLogger.info('DocAdenice JWT HMAC mode disabled'); } const container: Container = { @@ -106,6 +125,7 @@ export async function initContainer(opts: InitOptions): Promise { repos, tokens, oidc, + docmostJwt, groupsScopesMap, logger: rootLogger, }; diff --git a/bridge/src/middleware/auth.ts b/bridge/src/middleware/auth.ts index 37a6a44..03a1d59 100644 --- a/bridge/src/middleware/auth.ts +++ b/bridge/src/middleware/auth.ts @@ -1,32 +1,42 @@ /** - * Auth middleware bridge — dual mode : + * Auth middleware bridge — triple mode (R2.3b) : * * 1. Service tokens `brg_*` (`Authorization: ApiKey brg_*` ou `Bearer brg_*`) * pour M2M (webhooks emis par scripts, admin tools, frontend serveur). Les * scopes sont declares dans `BRIDGE_API_TOKENS` (JSON env var). * - * 2. OIDC JWT Authentik (`Authorization: Bearer ` ou cookie `authToken`) - * pour utilisateurs Docmost/DocAdenice. Active uniquement si - * `AUTHENTIK_ISSUER` + `AUTHENTIK_JWKS_URI` + `AUTHENTIK_AUDIENCE` set. + * 2. OIDC JWT Authentik RS256/384/512 (`Authorization: Bearer ` ou + * cookie `authToken`) pour utilisateurs Docmost/DocAdenice via IdP. Active + * uniquement si `AUTHENTIK_ISSUER` + `AUTHENTIK_JWKS_URI` + + * `AUTHENTIK_AUDIENCE` set. + * + * 3. JWT HMAC DocAdenice HS256/384/512 (R2.3b — meme transports que mode 2) + * pour mode local sans Authentik. Active si `DOCMOST_APP_SECRET` set + * (>= 32 chars). Le claim `acadenice_permissions[]` alimente directement + * les scopes (pas de mapping groups -> scopes : DocAdenice resout tout). * * R1 — Plus de lookup `PersonneRepo.findByEmail` : le bridge est generique, * il ne connait pas la table Personne. Le mapping email -> permissions * metier est entierement cote DocAdenice (R2 RBAC dynamique). Les scopes * effectifs viennent de : - * - groups Authentik via `groupsScopesMap` - * - claim JWT `acadenice_permissions[]` (R2) — fallback vide si absent + * - mode 2 : groups Authentik via `groupsScopesMap` + claim + * `acadenice_permissions[]` (union) + * - mode 3 : claim `acadenice_permissions[]` direct (pas de groups) * - * Ordre de detection : - * - `Authorization: brg_*` -> service token - * - `Authorization: Bearer ` (commence par `eyJ`) -> JWT OIDC - * - Cookie `authToken` -> JWT OIDC - * - Sinon -> 401 AUTH_REQUIRED + * Routage JWT (mode 2 vs 3) : on decode le header JWT non verifie pour lire + * `alg`. RS* -> mode 2 si actif. HS* -> mode 3 si actif. Si l'algo n'a pas de + * mode actif correspondant -> 401 AUTH_INVALID (pas de fallback silencieux). */ import type { MiddlewareHandler } from 'hono'; import { getCookie } from 'hono/cookie'; import type { Logger } from 'pino'; import { errors } from '../lib/errors.js'; +import { + type DocmostJwtVerifier, + decodeJwtAlg, + extractDocmostPermissions, +} from './docmost-jwt-verifier.js'; import type { OidcVerifier } from './oidc-verifier.js'; import { extractEmail, extractGroups } from './oidc-verifier.js'; import { type GroupsScopesMap, computeOidcScopes } from './scopes.js'; @@ -37,7 +47,12 @@ export interface ApiTokenRecord { scopes: string[]; } -export type AuthSource = 'service-token' | 'oidc-jwt' | 'oidc-cookie'; +export type AuthSource = + | 'service-token' + | 'oidc-jwt' + | 'oidc-cookie' + | 'docmost-jwt' + | 'docmost-cookie'; export interface AuthenticatedUser { source: AuthSource; @@ -144,8 +159,10 @@ export function extractPermissions(payload: Record): string[] { export interface AuthMiddlewareOptions { tokens: ReadonlyMap; - /** Si null, le mode OIDC est desactive : tout JWT envoye -> 401. */ + /** Si null, le mode OIDC Authentik (RS*) est desactive. */ oidc: OidcVerifier | null; + /** Si null, le mode JWT HMAC DocAdenice (HS*) est desactive. */ + docmostJwt: DocmostJwtVerifier | null; /** Map groups Authentik -> scopes. */ groupsScopesMap: GroupsScopesMap; logger: Logger; @@ -167,10 +184,14 @@ function parseAuthHeader(header: string | undefined): ParsedHeader { return { scheme: null, value: null }; } +/** Familles d'algos JWT acceptees par le bridge. */ +const RSA_ALGS = new Set(['RS256', 'RS384', 'RS512']); +const HMAC_ALGS = new Set(['HS256', 'HS384', 'HS512']); + export function authMiddleware( opts: AuthMiddlewareOptions, ): MiddlewareHandler<{ Variables: AuthVariables }> { - const { tokens, oidc, groupsScopesMap, logger } = opts; + const { tokens, oidc, docmostJwt, groupsScopesMap, logger } = opts; return async (c, next) => { const headerRaw = c.req.header('Authorization'); @@ -202,52 +223,123 @@ export function authMiddleware( throw errors.authInvalid(); } - // --- OIDC JWT (Bearer non-brg_ ou cookie) ------------------------------ + // --- JWT (Bearer non-brg_ ou cookie) ----------------------------------- let jwt: string | null = null; - let source: AuthSource | null = null; + let isCookie = false; 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'; + isCookie = true; } 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'); + // Aucun mode JWT actif -> rejet explicite (pas de fallback silencieux). + if (!oidc && !docmostJwt) { + logger.warn({ isCookie }, 'JWT received but no JWT mode enabled'); 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); - const permissions = extractPermissions(verified.payload as Record); - const scopes = computeOidcScopes(groups, permissions, groupsScopesMap); - const user: AuthenticatedUser = { - source: source ?? 'oidc-jwt', - email: email ?? undefined, - sub, - groups, - permissions, - scopes, - }; - c.set('user', user); - c.set('auth', { - tokenName: email ?? sub ?? 'oidc-anonymous', - scopes: new Set(scopes), - }); - await next(); - return; + // Decode l'algo (sans verifier signature) pour router vers le bon mode. + const alg = decodeJwtAlg(jwt); + if (alg === null) { + logger.warn('JWT header undecodable'); + throw errors.authInvalid(); + } + + if (RSA_ALGS.has(alg)) { + if (!oidc) { + logger.warn({ alg }, 'RS* JWT received but OIDC mode disabled'); + throw errors.authInvalid(); + } + await verifyOidcAndAttach(c, jwt, oidc, groupsScopesMap, isCookie); + await next(); + return; + } + + if (HMAC_ALGS.has(alg)) { + if (!docmostJwt) { + logger.warn({ alg }, 'HS* JWT received but DocAdenice mode disabled'); + throw errors.authInvalid(); + } + await verifyDocmostAndAttach(c, jwt, docmostJwt, isCookie); + await next(); + return; + } + + // Algo non supporte (none, ES*, EdDSA, etc.). + logger.warn({ alg }, 'JWT algorithm not allowed'); + throw errors.authInvalid(); } throw errors.authRequired(); }; } + +// --------------------------------------------------------------------------- +// Helpers internes (factorise la logique attach pour chaque mode JWT) +// --------------------------------------------------------------------------- + +type AuthCtx = Parameters>[0]; + +async function verifyOidcAndAttach( + c: AuthCtx, + jwt: string, + oidc: OidcVerifier, + groupsScopesMap: GroupsScopesMap, + isCookie: boolean, +): Promise { + 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); + const permissions = extractPermissions(verified.payload as Record); + + const scopes = computeOidcScopes(groups, permissions, groupsScopesMap); + const user: AuthenticatedUser = { + source: isCookie ? 'oidc-cookie' : 'oidc-jwt', + email: email ?? undefined, + sub, + groups, + permissions, + scopes, + }; + c.set('user', user); + c.set('auth', { + tokenName: email ?? sub ?? 'oidc-anonymous', + scopes: new Set(scopes), + }); +} + +async function verifyDocmostAndAttach( + c: AuthCtx, + jwt: string, + verifier: DocmostJwtVerifier, + isCookie: boolean, +): Promise { + const { payload } = await verifier.verify(jwt); + const permissions = extractDocmostPermissions(payload); + const email = typeof payload.email === 'string' ? payload.email.toLowerCase() : undefined; + + // Pour DocAdenice : le claim `acadenice_permissions[]` est la source de + // verite — pas de mapping groups -> scopes (DocAdenice resout deja tout via + // son RBAC R2). On expose `permissions` ET `scopes` avec la meme valeur pour + // garder l'API `c.var.user` uniforme entre modes. + const user: AuthenticatedUser = { + source: isCookie ? 'docmost-cookie' : 'docmost-jwt', + email, + sub: payload.sub, + groups: [], + permissions, + scopes: permissions, + }; + c.set('user', user); + c.set('auth', { + tokenName: email ?? payload.sub, + scopes: new Set(permissions), + }); +} diff --git a/bridge/src/middleware/docmost-jwt-verifier.ts b/bridge/src/middleware/docmost-jwt-verifier.ts new file mode 100644 index 0000000..fd75319 --- /dev/null +++ b/bridge/src/middleware/docmost-jwt-verifier.ts @@ -0,0 +1,173 @@ +/** + * Verification JWT HMAC DocAdenice (Docmost fork). + * + * DocAdenice signe ses JWT en HS256 (algo natif Nest JwtModule) avec `appSecret` + * (cf `docmost/apps/server/src/core/auth/token.module.ts`). Le bridge accepte ces + * JWT comme troisieme source d'auth Bearer pour le mode local sans Authentik : + * + * - Le frontend DocAdenice continue d'appeler le bridge avec son cookie/Bearer + * Docmost natif, sans avoir besoin d'un IdP OIDC. + * - En prod avec Authentik branche, ce mode peut rester desactive + * (`DOCMOST_APP_SECRET` vide). + * + * Pourquoi pas reutiliser `OidcVerifier` : + * - HMAC vs RSA : algos differents et cles differentes (symetrique vs asymetrique). + * - Pas de JWKS remote a fetcher : la cle vit dans `process.env`. + * - Permet d'autoriser HS* uniquement ici (`OidcVerifier` les rejette + * explicitement pour eviter les confused-deputy via JWKS hostile). + * + * Constant-time : `jwtVerify` de `jose` utilise `node:crypto` qui fait la + * comparaison HMAC en temps constant via `crypto.timingSafeEqual`. + */ + +import { type KeyObject, createSecretKey } from 'node:crypto'; +import { type JWTPayload, type JWTVerifyResult, errors as joseErrors, jwtVerify } from 'jose'; +import type { Logger } from 'pino'; +import { errors } from '../lib/errors.js'; + +export interface DocmostJwtVerifierOptions { + /** APP_SECRET partage avec Docmost server. Doit etre >= 32 chars. */ + secret: string; + /** Claim `iss` attendu. Docmost natif emet "Docmost". */ + issuer: string; + /** Claim `aud` optionnel. Si set, verifie. Si non set, claim ignore. */ + audience?: string; + logger: Logger; +} + +/** + * Shape minimale du payload JWT DocAdenice (cf + * `docmost/.../auth/dto/jwt-payload.ts` + `token.service.ts#generateAccessToken`). + * + * `email` n'est pas systematiquement present (collab/exchange/api-key tokens + * l'omettent), c'est pourquoi il est optionnel ici. + * + * `acadenice_permissions` est ajoute par R2.1 cote DocAdenice via + * `AcadeniceRoleService.getUserPermissions()` au moment du sign. + */ +export interface DocmostJwtPayload extends JWTPayload { + sub: string; + email?: string; + workspaceId: string; + type: string; + sessionId?: string; + acadenice_permissions?: string[]; +} + +const HMAC_ALGORITHMS = ['HS256', 'HS384', 'HS512'] as const; + +const MIN_SECRET_BYTES = 32; + +export class DocmostJwtVerifier { + private readonly secretKey: KeyObject; + private readonly issuer: string; + private readonly audience: string | undefined; + private readonly logger: Logger; + + constructor(opts: DocmostJwtVerifierOptions) { + if (!opts.secret || Buffer.byteLength(opts.secret, 'utf-8') < MIN_SECRET_BYTES) { + throw new Error( + `DocmostJwtVerifier: secret too short (>= ${MIN_SECRET_BYTES} bytes required)`, + ); + } + if (!opts.issuer || opts.issuer.length === 0) { + throw new Error('DocmostJwtVerifier: issuer required'); + } + this.secretKey = createSecretKey(Buffer.from(opts.secret, 'utf-8')); + this.issuer = opts.issuer; + this.audience = opts.audience; + this.logger = opts.logger.child({ middleware: 'docmost-jwt-verifier' }); + } + + /** + * Verifie un JWT HS*. Throw `errors.authInvalid()` si : + * - signature mauvaise (mauvais secret) + * - expired + * - issuer mismatch + * - audience mismatch (si set) + * - algo pas dans HMAC_ALGORITHMS (refuse RS*, none, etc.) + * - claims requis manquants (sub, workspaceId, type) + * + * Pas d'effet de bord : pas de cache, pas de network — la cle est locale. + */ + async verify(token: string): Promise { + let result: JWTVerifyResult; + try { + result = await jwtVerify(token, this.secretKey, { + issuer: this.issuer, + ...(this.audience ? { audience: this.audience } : {}), + algorithms: [...HMAC_ALGORITHMS], + }); + } catch (err) { + const code = (err as { code?: string }).code ?? 'JWT_VERIFY_FAILED'; + // joseErrors.JWTExpired / JWTClaimValidationFailed / JWSSignatureVerificationFailed + // pour distinguer dans les metriques sans logger le token. + const reason = + err instanceof joseErrors.JWTExpired + ? 'expired' + : err instanceof joseErrors.JWTClaimValidationFailed + ? 'claim-mismatch' + : err instanceof joseErrors.JWSSignatureVerificationFailed + ? 'bad-signature' + : err instanceof joseErrors.JOSEAlgNotAllowed + ? 'algo-not-allowed' + : 'verify-failed'; + this.logger.warn( + { code, reason, message: (err as Error).message }, + 'DocAdenice JWT verification failed', + ); + throw errors.authInvalid(); + } + + const payload = result.payload as Record; + if (typeof payload.sub !== 'string' || payload.sub.length === 0) { + this.logger.warn('DocAdenice JWT missing sub claim'); + throw errors.authInvalid(); + } + if (typeof payload.workspaceId !== 'string' || payload.workspaceId.length === 0) { + this.logger.warn({ sub: payload.sub }, 'DocAdenice JWT missing workspaceId claim'); + throw errors.authInvalid(); + } + if (typeof payload.type !== 'string' || payload.type.length === 0) { + this.logger.warn({ sub: payload.sub }, 'DocAdenice JWT missing type claim'); + throw errors.authInvalid(); + } + + return result as JWTVerifyResult & { payload: DocmostJwtPayload }; + } +} + +/** + * Decode le header JWT sans verifier la signature pour determiner l'algo. + * Utilise par le middleware d'auth pour router vers Authentik (RS*) ou + * DocAdenice (HS*). + * + * Retourne null si format invalide. Le caller doit alors rejeter en + * AUTH_INVALID — un JWT sans header decodable n'est pas legitime. + */ +export function decodeJwtAlg(token: string): string | null { + const firstDot = token.indexOf('.'); + if (firstDot <= 0) return null; + const headerB64 = token.slice(0, firstDot); + try { + const json = Buffer.from(headerB64, 'base64url').toString('utf-8'); + const header = JSON.parse(json) as Record; + if (typeof header.alg === 'string' && header.alg.length > 0) return header.alg; + return null; + } catch { + return null; + } +} + +/** + * Extrait `acadenice_permissions[]` d'un payload JWT DocAdenice. Tolerant : + * accepte un tableau de strings, ignore les valeurs non-strings ou vides. + * + * Note : meme logique que `extractPermissions` dans `auth.ts`. Garde une copie + * locale ici pour eviter la dependance cyclique (auth.ts importe ce fichier). + */ +export function extractDocmostPermissions(payload: DocmostJwtPayload): string[] { + const raw = payload.acadenice_permissions; + if (!Array.isArray(raw)) return []; + return raw.filter((p): p is string => typeof p === 'string' && p.length > 0); +} diff --git a/bridge/tests/middleware/auth.test.ts b/bridge/tests/middleware/auth.test.ts index 65d5d22..3341f55 100644 --- a/bridge/tests/middleware/auth.test.ts +++ b/bridge/tests/middleware/auth.test.ts @@ -9,6 +9,7 @@ * que mocker `createRemoteJWKSet` car teste fetch reel + parsing reel. */ +import { randomBytes } from 'node:crypto'; import { type Server, createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import { Hono } from 'hono'; @@ -24,6 +25,7 @@ import { parseTokens, requireScope, } from '../../src/middleware/auth.js'; +import { DocmostJwtVerifier } from '../../src/middleware/docmost-jwt-verifier.js'; import { errorHandler } from '../../src/middleware/error-handler.js'; import { OidcVerifier } from '../../src/middleware/oidc-verifier.js'; @@ -97,6 +99,9 @@ interface BuildAppOpts { oidcEnabled: boolean; jwks?: JwksFixture; groupsScopesMap?: Record; + docmostSecret?: string; + docmostIssuer?: string; + docmostAudience?: string; } function buildApp(opts: BuildAppOpts) { @@ -116,6 +121,16 @@ function buildApp(opts: BuildAppOpts) { }); } + let docmostJwt: DocmostJwtVerifier | null = null; + if (opts.docmostSecret) { + docmostJwt = new DocmostJwtVerifier({ + secret: opts.docmostSecret, + issuer: opts.docmostIssuer ?? 'Docmost', + audience: opts.docmostAudience, + logger, + }); + } + const app = new Hono<{ Variables: AuthVariables }>(); app.onError(errorHandler); app.use( @@ -123,6 +138,7 @@ function buildApp(opts: BuildAppOpts) { authMiddleware({ tokens: map, oidc: verifier, + docmostJwt, groupsScopesMap: opts.groupsScopesMap ?? {}, logger, }), @@ -459,3 +475,252 @@ describe('auth middleware — OIDC actif (R1 generique)', () => { expect(body.error.code).toBe('FORBIDDEN_SCOPE'); }); }); + +// --------------------------------------------------------------------------- +// R2.3b — Mode JWT HMAC DocAdenice +// --------------------------------------------------------------------------- + +const DOCMOST_SECRET = randomBytes(48).toString('hex'); + +async function signDocmostJwt( + claims: Record, + overrides?: { issuer?: string; audience?: string; expiresIn?: string; secret?: string }, +): Promise { + const builder = new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setIssuer(overrides?.issuer ?? 'Docmost') + .setExpirationTime(overrides?.expiresIn ?? '5m'); + if (overrides?.audience) builder.setAudience(overrides.audience); + return builder.sign(Buffer.from(overrides?.secret ?? DOCMOST_SECRET, 'utf-8')); +} + +describe('auth middleware — JWT HMAC DocAdenice (R2.3b)', () => { + it('Bearer JWT HS256 DocAdenice valide -> 200 + source=docmost-jwt', async () => { + const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET }); + const token = await signDocmostJwt({ + sub: 'docmost-user-uuid', + email: 'corentin@acadenice.fr', + workspaceId: 'ws-1', + type: 'access', + sessionId: 'sess-1', + acadenice_permissions: ['read:tables', 'write:tables'], + }); + 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; + email: string; + sub: string; + scopes: string[]; + permissions: string[]; + groups: string[]; + }; + }; + expect(body.user.source).toBe('docmost-jwt'); + expect(body.user.email).toBe('corentin@acadenice.fr'); + expect(body.user.sub).toBe('docmost-user-uuid'); + expect(body.user.scopes).toEqual(['read:tables', 'write:tables']); + expect(body.user.permissions).toEqual(['read:tables', 'write:tables']); + expect(body.user.groups).toEqual([]); + }); + + it('Bearer JWT HS256 sans permissions claim -> auth OK + scopes vides', async () => { + const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET }); + const token = await signDocmostJwt({ + sub: 'u', + workspaceId: 'w', + type: 'access', + }); + 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[]; permissions: string[] } }; + expect(body.user.scopes).toEqual([]); + expect(body.user.permissions).toEqual([]); + }); + + it('Bearer JWT HS256 mais DocAdenice mode desactive -> 401 AUTH_INVALID', async () => { + const { app } = buildApp({ oidcEnabled: false }); + const token = await signDocmostJwt({ + sub: 'u', + workspaceId: 'w', + type: 'access', + }); + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('AUTH_INVALID'); + }); + + it('Cookie authToken HS256 DocAdenice -> 200 + source=docmost-cookie', async () => { + const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET }); + const token = await signDocmostJwt({ + sub: 'cookie-user', + email: 'cookie@acadenice.fr', + workspaceId: 'ws-1', + type: 'access', + acadenice_permissions: ['read:tables'], + }); + 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; email: string } }; + expect(body.user.source).toBe('docmost-cookie'); + expect(body.user.email).toBe('cookie@acadenice.fr'); + }); + + it('JWT HS256 expired -> 401', async () => { + const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET }); + const token = await signDocmostJwt( + { sub: 'u', workspaceId: 'w', type: 'access' }, + { expiresIn: '-1s' }, + ); + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + }); + + it('JWT HS256 wrong signature (mauvais secret) -> 401', async () => { + const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET }); + const token = await signDocmostJwt( + { sub: 'u', workspaceId: 'w', type: 'access' }, + { secret: randomBytes(48).toString('hex') }, + ); + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + }); + + it('JWT HS256 issuer mismatch -> 401', async () => { + const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET }); + const token = await signDocmostJwt( + { sub: 'u', workspaceId: 'w', type: 'access' }, + { issuer: 'Acme' }, + ); + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + }); + + it('JWT HS256 + DocAdenice permissions -> requireScope match -> 200', async () => { + const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET }); + const token = await signDocmostJwt({ + sub: 'u', + workspaceId: 'w', + type: 'access', + acadenice_permissions: ['read:tables'], + }); + const res = await app.request('/protected/needs-read-tables', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + }); +}); + +describe('auth middleware — coexistence Authentik + DocAdenice (R2.3b)', () => { + let jwks: JwksFixture; + + beforeAll(async () => { + jwks = await startJwksServer(); + }); + + afterAll(async () => { + await new Promise((resolve) => jwks.server.close(() => resolve())); + }); + + it('JWT RS256 Authentik valide -> route vers OIDC (mode 2)', async () => { + const { app } = buildApp({ + oidcEnabled: true, + jwks, + docmostSecret: DOCMOST_SECRET, + }); + const token = await signJwt(jwks, { + email: 'jane@acadenice.fr', + sub: 'authentik-jane', + acadenice_permissions: ['read:tables'], + }); + 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 } }; + expect(body.user.source).toBe('oidc-jwt'); + }); + + it('JWT HS256 DocAdenice valide -> route vers DocAdenice (mode 3)', async () => { + const { app } = buildApp({ + oidcEnabled: true, + jwks, + docmostSecret: DOCMOST_SECRET, + }); + const token = await signDocmostJwt({ + sub: 'docmost-user', + workspaceId: 'ws-1', + type: 'access', + acadenice_permissions: ['read:tables'], + }); + 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 } }; + expect(body.user.source).toBe('docmost-jwt'); + }); + + it('JWT RS256 mais OIDC desactive (DocAdenice seul) -> 401 (algo mismatch)', async () => { + const { app } = buildApp({ + oidcEnabled: false, + docmostSecret: DOCMOST_SECRET, + }); + const token = await signJwt(jwks, { email: 'x@y.z' }); + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('AUTH_INVALID'); + }); + + it('JWT avec algo none -> 401 (algo non supporte)', async () => { + const { app } = buildApp({ + oidcEnabled: true, + jwks, + docmostSecret: DOCMOST_SECRET, + }); + // header algo "none" — manuellement forge. + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }), 'utf-8').toString( + 'base64url', + ); + const payload = Buffer.from(JSON.stringify({ sub: 'u' }), 'utf-8').toString('base64url'); + const noneJwt = `${header}.${payload}.`; + const res = await app.request('/protected/me', { + headers: { Authorization: `Bearer ${noneJwt}` }, + }); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('AUTH_INVALID'); + }); + + it('JWT garbage (header non decodable) -> 401', async () => { + const { app } = buildApp({ + oidcEnabled: true, + jwks, + docmostSecret: DOCMOST_SECRET, + }); + const res = await app.request('/protected/me', { + headers: { Authorization: 'Bearer notajwt.atall.bro' }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/bridge/tests/middleware/docmost-jwt-verifier.test.ts b/bridge/tests/middleware/docmost-jwt-verifier.test.ts new file mode 100644 index 0000000..9467b14 --- /dev/null +++ b/bridge/tests/middleware/docmost-jwt-verifier.test.ts @@ -0,0 +1,289 @@ +/** + * Tests unitaires DocmostJwtVerifier (R2.3b). + * + * Strategie : pas de mock — on signe a la volee avec `jose.SignJWT` (le meme + * code que celui qu'utilise NestJS `JwtService` cote Docmost). Le verifier est + * le code sous test, pas son entourage. + */ + +import { randomBytes } from 'node:crypto'; +import { SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { describe, expect, it } from 'vitest'; +import { logger } from '../../src/lib/logger.js'; +import { + DocmostJwtVerifier, + decodeJwtAlg, + extractDocmostPermissions, +} from '../../src/middleware/docmost-jwt-verifier.js'; + +// Secret 64 chars — au-dessus du minimum 32. +const DEFAULT_SECRET = randomBytes(48).toString('hex'); +const DEFAULT_ISSUER = 'Docmost'; + +interface SignOpts { + secret?: string; + issuer?: string; + audience?: string; + alg?: 'HS256' | 'HS384' | 'HS512'; + expiresIn?: string; + claims?: Record; +} + +async function signHsJwt(opts: SignOpts = {}): Promise { + const secret = opts.secret ?? DEFAULT_SECRET; + const claims = opts.claims ?? { + sub: 'user-uuid-1', + workspaceId: 'ws-uuid-1', + type: 'access', + }; + const builder = new SignJWT(claims) + .setProtectedHeader({ alg: opts.alg ?? 'HS256' }) + .setIssuedAt() + .setIssuer(opts.issuer ?? DEFAULT_ISSUER) + .setExpirationTime(opts.expiresIn ?? '5m'); + if (opts.audience) builder.setAudience(opts.audience); + return builder.sign(Buffer.from(secret, 'utf-8')); +} + +function makeVerifier(overrides?: { secret?: string; issuer?: string; audience?: string }) { + return new DocmostJwtVerifier({ + secret: overrides?.secret ?? DEFAULT_SECRET, + issuer: overrides?.issuer ?? DEFAULT_ISSUER, + audience: overrides?.audience, + logger, + }); +} + +describe('DocmostJwtVerifier — constructor', () => { + it('throw si secret < 32 bytes', () => { + expect( + () => new DocmostJwtVerifier({ secret: 'x'.repeat(31), issuer: 'Docmost', logger }), + ).toThrow(/secret too short/); + }); + + it('throw si issuer vide', () => { + expect(() => new DocmostJwtVerifier({ secret: DEFAULT_SECRET, issuer: '', logger })).toThrow( + /issuer required/, + ); + }); + + it('construit ok avec secret 32 bytes minimum', () => { + expect( + () => new DocmostJwtVerifier({ secret: 'a'.repeat(32), issuer: 'Docmost', logger }), + ).not.toThrow(); + }); +}); + +describe('DocmostJwtVerifier — verify happy paths', () => { + it('JWT HS256 valide + issuer match -> ok + payload', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ + claims: { + sub: 'jane-uuid', + email: 'jane@acadenice.fr', + workspaceId: 'ws-1', + type: 'access', + sessionId: 'sess-1', + }, + }); + const { payload } = await v.verify(token); + expect(payload.sub).toBe('jane-uuid'); + expect(payload.email).toBe('jane@acadenice.fr'); + expect(payload.workspaceId).toBe('ws-1'); + expect(payload.type).toBe('access'); + expect(payload.sessionId).toBe('sess-1'); + }); + + it('JWT HS384 valide -> ok (algo accepte)', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ alg: 'HS384' }); + const { payload } = await v.verify(token); + expect(payload.sub).toBe('user-uuid-1'); + }); + + it('JWT HS512 valide -> ok', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ alg: 'HS512' }); + const { payload } = await v.verify(token); + expect(payload.sub).toBe('user-uuid-1'); + }); + + it('JWT sans claim acadenice_permissions -> ok, scopes vides cote caller', async () => { + const v = makeVerifier(); + const token = await signHsJwt(); + const { payload } = await v.verify(token); + expect(extractDocmostPermissions(payload)).toEqual([]); + }); + + it('JWT avec claim acadenice_permissions[] -> scopes alimentes', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ + claims: { + sub: 'u', + workspaceId: 'w', + type: 'access', + acadenice_permissions: ['read:tables', 'write:tables'], + }, + }); + const { payload } = await v.verify(token); + expect(extractDocmostPermissions(payload)).toEqual(['read:tables', 'write:tables']); + }); + + it('JWT avec audience matching quand verifier configure -> ok', async () => { + const v = makeVerifier({ audience: 'formation-hub-bridge' }); + const token = await signHsJwt({ audience: 'formation-hub-bridge' }); + const { payload } = await v.verify(token); + expect(payload.sub).toBe('user-uuid-1'); + }); + + it('JWT sans audience claim quand verifier ne configure pas d audience -> ok', async () => { + const v = makeVerifier(); + const token = await signHsJwt(); + const { payload } = await v.verify(token); + expect(payload).toBeDefined(); + }); +}); + +describe('DocmostJwtVerifier — verify rejections', () => { + it('JWT HS256 expired -> throws AUTH_INVALID', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ expiresIn: '-1s' }); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID', status: 401 }); + }); + + it('JWT HS256 wrong issuer -> throws', async () => { + const v = makeVerifier({ issuer: 'Docmost' }); + const token = await signHsJwt({ issuer: 'EvilCorp' }); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT HS256 wrong signature (different secret) -> throws', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ secret: randomBytes(48).toString('hex') }); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT RS256 (mauvais algo) -> throws (algorithm mismatch)', async () => { + const v = makeVerifier(); + const { privateKey } = await generateKeyPair('RS256'); + const rsaToken = await new SignJWT({ + sub: 'u', + workspaceId: 'w', + type: 'access', + }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setIssuer(DEFAULT_ISSUER) + .setExpirationTime('5m') + .sign(privateKey); + await expect(v.verify(rsaToken)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT audience mismatch quand verifier exige audience -> throws', async () => { + const v = makeVerifier({ audience: 'formation-hub-bridge' }); + const token = await signHsJwt({ audience: 'other-app' }); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT sans audience quand verifier exige audience -> throws', async () => { + const v = makeVerifier({ audience: 'formation-hub-bridge' }); + const token = await signHsJwt(); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT sans claim sub -> throws (claim requis)', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ + claims: { workspaceId: 'w', type: 'access' }, + }); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT sans claim workspaceId -> throws', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ + claims: { sub: 'u', type: 'access' }, + }); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT sans claim type -> throws', async () => { + const v = makeVerifier(); + const token = await signHsJwt({ + claims: { sub: 'u', workspaceId: 'w' }, + }); + await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); + + it('JWT malforme -> throws', async () => { + const v = makeVerifier(); + await expect(v.verify('not.a.jwt')).rejects.toMatchObject({ code: 'AUTH_INVALID' }); + }); +}); + +describe('decodeJwtAlg', () => { + it('retourne alg pour un JWT HS256', async () => { + const token = await signHsJwt(); + expect(decodeJwtAlg(token)).toBe('HS256'); + }); + + it('retourne alg pour un JWT HS384', async () => { + const token = await signHsJwt({ alg: 'HS384' }); + expect(decodeJwtAlg(token)).toBe('HS384'); + }); + + it('retourne alg pour un JWT RS256', async () => { + const { privateKey } = await generateKeyPair('RS256'); + const token = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .sign(privateKey); + expect(decodeJwtAlg(token)).toBe('RS256'); + }); + + it('retourne null pour une string sans dot', () => { + expect(decodeJwtAlg('garbage')).toBeNull(); + }); + + it('retourne null pour un header non-base64', () => { + expect(decodeJwtAlg('!!!!.payload.sig')).toBeNull(); + }); + + it('retourne null si JSON sans alg', () => { + const headerB64 = Buffer.from(JSON.stringify({ typ: 'JWT' }), 'utf-8').toString('base64url'); + expect(decodeJwtAlg(`${headerB64}.x.y`)).toBeNull(); + }); +}); + +describe('extractDocmostPermissions', () => { + it('retourne [] si claim absent', () => { + expect(extractDocmostPermissions({ sub: 'u', workspaceId: 'w', type: 'access' })).toEqual([]); + }); + + it('retourne [] si claim pas un array', () => { + expect( + extractDocmostPermissions({ + sub: 'u', + workspaceId: 'w', + type: 'access', + // biome-ignore lint/suspicious/noExplicitAny: test du runtime tolerance + acadenice_permissions: 'foo' as any, + }), + ).toEqual([]); + }); + + it('filtre les non-strings et vides', () => { + expect( + extractDocmostPermissions({ + sub: 'u', + workspaceId: 'w', + type: 'access', + // biome-ignore lint/suspicious/noExplicitAny: test du runtime tolerance + acadenice_permissions: ['ok', 1, '', null, 'good'] as any, + }), + ).toEqual(['ok', 'good']); + }); +}); + +// Petite verif que `exportJWK` est utilise nulle part (pas de warning unused). +void exportJWK; diff --git a/bridge/vitest.config.ts b/bridge/vitest.config.ts index bae48f1..418e977 100644 --- a/bridge/vitest.config.ts +++ b/bridge/vitest.config.ts @@ -37,6 +37,13 @@ export default defineConfig({ branches: 85, statements: 85, }, + // R2.3b : verifier JWT HMAC DocAdenice. + 'src/middleware/docmost-jwt-verifier.ts': { + lines: 85, + functions: 85, + branches: 85, + statements: 85, + }, // Bloc 5 : rate limit middleware + cache helper. 'src/middleware/rate-limit.ts': { lines: 85,