Aller au contenu

ADR-0011 — Scoping des JWT par audience (realm) et durcissement des tokens

Date : 2026-06-10 Catégorie : A (convention de sécurité transverse, dans le cadre de l'auth centralisée déjà validée) Décideur : Lead architecte DYNORS Statut : Acceptée — implémentée 2026-06-11 Implémentation : - core 3620258 (audience String→Set, validation stricte, émission aud/iss) - core 6a5e628 (JwtProductionReadinessValidator fail-fast prod) - core 5e128c9 (validation regex tech code layer 3 — voir aussi IDENTITE_APPLICATIVE_3_LAYERS.md) - dynors-internal d56b842 (slots Codex + activation 5 apps internes) - dynors-paiement c8ae5cd · dynors-projects/dawalale|jaraaf|mediconnect-sn|tracium (audience par app activée) Réalise : ADR-0002 (backfill « JWT minimal avec aud realm ») Reste à faire : palier 2 — signature asymétrique RS256/EdDSA (dynors-auth JWKS), TTL access courts + rotation refresh tokens. Activable progressivement via TAIL_AUTH_KEY_MODE=RS256_ASYMMETRIC (slot Codex existant).


Contexte

Constat vérifié dans le code et la configuration (2026-06-10) :

  1. La validation d'audience est dormante partout. JwtTokenProvider.validateToken (core/.../security/jwt/JwtTokenProvider.java) sait valider le claim aud — mais uniquement si dynors.security.jwt.audience est configuré. Aucune app du workspace ne le configure (grep audience: sur tous les application.yml → vide). Le contrôle est donc systématiquement court-circuité.
  2. Secret commun, pas d'aud à l'émission. Toutes les apps lisent la même variable ${JWT_SECRET} ; en environnement déployé, un émetteur central signe des tokens que toutes les apps valident avec ce secret partagé. DAWALALE (AuthLoginService) émet un token portant tenant, email, rolessans aud.
  3. Conséquence : rejeu inter-plateformes. Un token émis pour un élève DAWALALE est authentifié par le filtre JWT de JARAAF, FISCAL, BOOKS, etc. (signature valide, non expiré, aucun contrôle d'audience). Le RBAC bloque ensuite les actions non autorisées, mais le token a déjà franchi l'authentification — toute route « utilisateur authentifié » (profil, /me, endpoints sans rôle spécifique) devient atteignable, et une collision de nom de rôle entre deux apps (ROLE_USER…) peut élargir la brèche.
  4. TTL trop long. DAWALALE : JWT_EXPIRATION=86400 (24 h) pour un token d'accès.

La défense doit être en deux couches distinctes : l'aud scope le token à une plateforme (il ne doit pas être accepté ailleurs) ; le rôle scope les actions dans cette plateforme. Aujourd'hui seule la seconde couche existe.

Décision

1. aud (realm) obligatoire — émission et validation

  • Tout JWT utilisateur porte un aud identifiant le realm cible. Realms DYNORS (alignés ADR-0002) :
Realm aud Périmètre
dawalale plateforme DAWALALE (élèves, moniteurs)
jaraaf plateforme RH JARAAF
fiscal plateforme FISCAL
{app} une valeur par plateforme exposée (= CORE_APP_CODE)
internal back-offices internes DYNORS partageant un SSO interne (à acter app par app)
  • Chaque app déclare l'audience qu'elle accepte et rejette tout token dont l'aud ne correspond pas. La validation devient obligatoire, plus optionnelle : un token sans aud, ou avec un aud étranger, est rejeté (401), avant tout examen de rôle.
  • Une app peut accepter plusieurs audiences (ex. un back-office acceptant {app} et internal) — d'où l'évolution de SecurityProperties.jwt.audience d'une String unique vers un ensemble d'audiences acceptées (rétro-compatible : une seule valeur reste valide).

2. Émission centralisée et iss validé

  • L'émission reste la responsabilité de dynors-auth (rappel CLAUDE.md §8). Les logins locaux résiduels (ex. AuthLoginService DAWALALE) sont une dérogation à résorber ; tant qu'ils existent, ils doivent poser aud = leur propre realm et iss = l'émetteur.
  • iss est validé en plus de aud (les deux contrôles de validateToken activés ensemble).

3. Durcissement (best practices)

  • TTL access court : 15 min cible (max 60), + refresh token à durée plus longue, rotation au refresh. Fin des access tokens de 24 h.
  • Cookies HttpOnly Secure SameSite exclusivement (rappel ADR-0006) — jamais localStorage.
  • Clé de signature par realm (ou signature asymétrique RS256/EdDSA : dynors-auth signe avec une clé privée, les apps valident avec la publique). Objectif : la fuite du secret d'un realm ne permet pas de forger un token accepté par un autre. Même logique d'isolement que l'ADR-0008 pour le transit SLY. La signature asymétrique est la cible (les apps n'ont alors aucun secret de forge, juste une clé publique de vérification).
  • Pas de rôles porteurs de privilège cross-app dans le token : les rôles du token valent pour le realm du token. L'autorisation fine reste résolue côté app (RBAC dynors-security
  • dynors-entitlements, cf. ADR-0003).

Conséquences

Bénéfices

  • Un token DAWALALE est rejeté à l'authentification par toute autre plateforme — la faille de rejeu inter-plateformes est fermée, indépendamment du RBAC.
  • Défense en profondeur explicite : scope plateforme (aud) + scope actions (rôles).
  • La fuite d'un secret de realm ne compromet pas les autres (clé par realm / asymétrique).
  • Fenêtre d'exposition d'un token volé réduite (TTL court + refresh).

Coûts / contraintes induites

  • Chaque app doit déclarer son ou ses audiences acceptées — config + redéploiement coordonné.
  • dynors-auth (et les logins locaux résiduels) doivent poser aud/iss systématiquement.
  • Migration ordonnée pour ne pas invalider les sessions en cours : d'abord émettre aud partout, puis activer le rejet (fenêtre où l'émission pose aud mais la validation ne le rend pas encore obligatoire).
  • Le passage à la signature asymétrique demande une gestion de clés (JWKS) — peut être un second temps après l'aud obligatoire.

Composants impactés

  • core/.../securitySecurityProperties.jwt.audience (String → Set), JwtTokenProvider (audience obligatoire si l'app le déclare ; rejet si aud absent/étranger).
  • Chaque application.yml d'app — dynors.security.jwt.audience + issuer.
  • dynors-auth — émission aud par realm, TTL courts, refresh.
  • Codex SIRRAT — TAIL_AUTH_AUDIENCE (nouvelle variable, = CORE_APP_CODE par défaut).
  • DAWALALE AuthLoginService — poser aud, raccourcir le TTL.

Alternatives considérées

Alternative A — Statu quo (RBAC seul). Écartée : le token étranger est authentifié avant le RBAC ; toute route sans rôle requis est exposée, et les collisions de noms de rôles sont un risque réel. L'authentification ne doit pas dépendre du hasard du nommage des rôles.

Alternative B — Un secret JWT distinct par app, sans aud. Écartée : empêche le rejeu si les secrets ne sont jamais partagés, mais casse l'auth centralisée (dynors-auth ne pourrait plus signer pour tous) et ne porte pas la sémantique de realm. L'aud est le mécanisme standard (RFC 7519) prévu pour ça.

Alternative C — Audience optionnelle (laisser au choix de l'app). Écartée : c'est l'état actuel, et il a produit la faille. La validation doit être obligatoire dès qu'une app déclare son audience, et toute app exposée doit en déclarer une (vérifié au démarrage).

Plan d'exécution

  1. SecurityProperties.jwt.audience : accepter un ensemble ; JwtTokenProvider rejette aud absent/non-déclaré quand l'app a déclaré son audience.
  2. Faire poser aud (= realm) et iss par dynors-auth et par les logins locaux résiduels.
  3. Déclarer dynors.security.jwt.audience dans chaque application.yml (= CORE_APP_CODE), via slot Codex TAIL_AUTH_AUDIENCE.
  4. Activer le rejet une fois l'émission aud généralisée (fin de la fenêtre de migration).
  5. Raccourcir les TTL access + refresh token + rotation.
  6. (Second temps) Signature asymétrique RS256/EdDSA + JWKS exposé par dynors-auth.

Suivi

Critère de succès : un token émis pour le realm dawalale est rejeté en 401 par toute app dont l'audience déclarée ne contient pas dawalale, avant toute évaluation de rôle. Test de sécurité : rejouer un token DAWALALE valide contre api.jaraaf → 401.

Signal de régression : toute app exposée sans dynors.security.jwt.audience déclarée (à vérifier au démarrage, comme SlyProductionReadinessValidator le fait pour le forward-secret).

Références

  • Décision réalisée : ADR-0002 (backfill aud realm)
  • Cookies tokens : ADR-0006
  • Isolement de secret (analogie transit) : ADR-0008
  • Autorisation runtime : ADR-0003 (dynors-entitlements)
  • Règles : CLAUDE.md §8, .cursor/rules/security-interapp-tenants.mdc §1
  • Code : core/.../security/jwt/JwtTokenProvider.java, dynors-projects/dawalale/.../auth/AuthLoginService.java