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) :
- La validation d'audience est dormante partout.
JwtTokenProvider.validateToken(core/.../security/jwt/JwtTokenProvider.java) sait valider le claimaud— mais uniquement sidynors.security.jwt.audienceest configuré. Aucune app du workspace ne le configure (grep audience:sur tous lesapplication.yml→ vide). Le contrôle est donc systématiquement court-circuité. - 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 portanttenant,email,roles— sansaud. - 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. - 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
audidentifiant 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'
audne correspond pas. La validation devient obligatoire, plus optionnelle : un token sansaud, ou avec unaudétranger, est rejeté (401), avant tout examen de rôle. - Une app peut accepter plusieurs audiences (ex. un back-office acceptant
{app}etinternal) — d'où l'évolution deSecurityProperties.jwt.audienced'uneStringunique 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.AuthLoginServiceDAWALALE) sont une dérogation à résorber ; tant qu'ils existent, ils doivent poseraud= leur propre realm etiss= l'émetteur. issest validé en plus deaud(les deux contrôles devalidateTokenactivé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-authsigne 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 poseraud/isssystématiquement.- Migration ordonnée pour ne pas invalider les sessions en cours : d'abord émettre
audpartout, puis activer le rejet (fenêtre où l'émission poseaudmais 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'
audobligatoire.
Composants impactés¶
core/.../security—SecurityProperties.jwt.audience(String → Set),JwtTokenProvider(audience obligatoire si l'app le déclare ; rejet siaudabsent/étranger).- Chaque
application.ymld'app —dynors.security.jwt.audience+issuer. dynors-auth— émissionaudpar realm, TTL courts, refresh.- Codex SIRRAT —
TAIL_AUTH_AUDIENCE(nouvelle variable, =CORE_APP_CODEpar défaut). - DAWALALE
AuthLoginService— poseraud, 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¶
SecurityProperties.jwt.audience: accepter un ensemble ;JwtTokenProviderrejetteaudabsent/non-déclaré quand l'app a déclaré son audience.- Faire poser
aud(= realm) etisspardynors-authet par les logins locaux résiduels. - Déclarer
dynors.security.jwt.audiencedans chaqueapplication.yml(=CORE_APP_CODE), via slot CodexTAIL_AUTH_AUDIENCE. - Activer le rejet une fois l'émission
audgénéralisée (fin de la fenêtre de migration). - Raccourcir les TTL access + refresh token + rotation.
- (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
audrealm) - 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