feat(auth): R2.3b bridge accepte JWT HMAC DocAdenice via DOCMOST_APP_SECRET
Some checks are pending
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Lint bridge (Biome) (push) Waiting to run
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

This commit is contained in:
Corentin JOGUET 2026-05-07 23:02:01 +02:00
parent 2ed73fa948
commit a79c51e6f2
10 changed files with 961 additions and 45 deletions

View file

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

View file

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

View file

@ -49,6 +49,7 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
authMiddleware({
tokens: ctn.tokens,
oidc: ctn.oidc,
docmostJwt: ctn.docmostJwt,
groupsScopesMap: ctn.groupsScopesMap,
logger: ctn.logger,
}),

View file

@ -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<Config, 'docmostAppSecret'>): boolean {
return Boolean(c.docmostAppSecret && c.docmostAppSecret.length >= 32);
}

View file

@ -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<string, ApiTokenRecord>;
/** 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<Container> {
'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<Container> {
repos,
tokens,
oidc,
docmostJwt,
groupsScopesMap,
logger: rootLogger,
};

View file

@ -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 <jwt>` 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 <jwt>` 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: <ApiKey|Bearer> brg_*` -> service token
* - `Authorization: Bearer <jwt>` (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, unknown>): string[] {
export interface AuthMiddlewareOptions {
tokens: ReadonlyMap<string, ApiTokenRecord>;
/** 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,28 +223,76 @@ 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();
}
// 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<MiddlewareHandler<{ Variables: AuthVariables }>>[0];
async function verifyOidcAndAttach(
c: AuthCtx,
jwt: string,
oidc: OidcVerifier,
groupsScopesMap: GroupsScopesMap,
isCookie: boolean,
): Promise<void> {
const verified = await oidc.verify(jwt);
const email = extractEmail(verified.payload);
const sub = typeof verified.payload.sub === 'string' ? verified.payload.sub : undefined;
@ -232,7 +301,7 @@ export function authMiddleware(
const scopes = computeOidcScopes(groups, permissions, groupsScopesMap);
const user: AuthenticatedUser = {
source: source ?? 'oidc-jwt',
source: isCookie ? 'oidc-cookie' : 'oidc-jwt',
email: email ?? undefined,
sub,
groups,
@ -244,10 +313,33 @@ export function authMiddleware(
tokenName: email ?? sub ?? 'oidc-anonymous',
scopes: new Set(scopes),
});
await next();
return;
}
throw errors.authRequired();
};
}
async function verifyDocmostAndAttach(
c: AuthCtx,
jwt: string,
verifier: DocmostJwtVerifier,
isCookie: boolean,
): Promise<void> {
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),
});
}

View file

@ -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<JWTVerifyResult & { payload: DocmostJwtPayload }> {
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<string, unknown>;
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<string, unknown>;
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);
}

View file

@ -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<string, string[]>;
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<string, unknown>,
overrides?: { issuer?: string; audience?: string; expiresIn?: string; secret?: string },
): Promise<string> {
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<void>((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);
});
});

View file

@ -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<string, unknown>;
}
async function signHsJwt(opts: SignOpts = {}): Promise<string> {
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;

View file

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