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
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:
parent
2ed73fa948
commit
a79c51e6f2
10 changed files with 961 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
173
bridge/src/middleware/docmost-jwt-verifier.ts
Normal file
173
bridge/src/middleware/docmost-jwt-verifier.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
289
bridge/tests/middleware/docmost-jwt-verifier.test.ts
Normal file
289
bridge/tests/middleware/docmost-jwt-verifier.test.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue