--- title: Backend — Patterns : Auth domain: backend bucket: patterns tags: [auth, requestid, api-errors, sessions, tokens] applies_to: [analysis, implementation, review, debug] severity: high validated_on: 2026-03-16 source_projects: [app-alexandrie] --- # Backend — Patterns : Auth > Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet. --- ## Pattern : Format d'erreur API standardisé - Objectif : fournir des erreurs prévisibles, exploitables et cohérentes pour tous les clients. - Contexte : API consommée par front-end, automatisations ou intégrations externes. - Quand l'utiliser : dès qu'une API est exposée à autre chose qu'un usage interne trivial. - Quand l'éviter : jamais. - Avantage : - Debug plus rapide - UX maîtrisée côté client - Observabilité améliorée - Limites / vigilance : - Discipline requise pour éviter les formats ad hoc - Validé le : 25-01-2026 - Contexte technique : API HTTP agnostique ### Implémentation (exemple minimal) ```json { "error": { "code": "USER_NOT_FOUND", "message": "Utilisateur introuvable", "requestId": "abc-123" } } ``` ### Checklist - Codes HTTP cohérents (4xx / 5xx) - Codes d'erreur applicatifs stables - Message utilisateur non technique - requestId présent --- ## Pattern : Middleware de corrélation (requestId / traceId) - Objectif : relier chaque requête aux logs et erreurs associées. - Contexte : toute API ou service exposé. - Quand l'utiliser : systématiquement en production. - Quand l'éviter : jamais. - Avantage : - MTTR réduit drastiquement - Debug cross-services possible - Limites / vigilance : - Doit être propagé partout (logs, erreurs, appels sortants) - Validé le : 25-01-2026 - Contexte technique : Backend agnostique (HTTP) ### Implémentation (exemple minimal) ```txt - Générer un requestId à l'entrée si absent - Le propager dans le contexte de requête - L'inclure dans chaque log et réponse d'erreur ``` ### Checklist - requestId généré ou repris d'un header existant - Présent dans tous les logs - Présent dans les erreurs retournées --- ## Pattern : Anti-énumération sur endpoints auth liés à un email - Objectif : empêcher qu'un endpoint auth révèle si un compte existe, n'existe pas ou n'est pas éligible. - Contexte : reset de mot de passe, invitation, vérification de compte, login ou tout flux qui part d'un email utilisateur. - Quand l'utiliser : dès qu'une requête auth touche un identifiant de type email. - Quand l'éviter : jamais sur une surface exposée. - Avantage : - réduit la fuite d'information sur les comptes existants - homogénéise les réponses côté client - se combine bien avec les garde-fous anti-abus - Limites / vigilance : - ne protège pas seul contre le brute-force, à combiner avec du rate-limiting - les logs internes doivent conserver la vraie cause sans l'exposer au client - Validé le : 16-03-2026 - Contexte technique : Node.js / auth applicative / API HTTP ### Implémentation (exemple minimal) ```txt - retourner la même réponse HTTP 200 qu'un compte existe ou non - ne jamais distinguer "email inconnu", "email connu" ou "compte OAuth-only" dans la réponse - journaliser la cause réelle côté serveur - ajouter un rate-limiting basé sur email + IP ``` ### Checklist - Réponse client uniforme pour les cas compte connu/inconnu/non éligible - Aucune fuite d'existence dans le message ou le code d'erreur - Rate-limiting présent sur les endpoints exposés - Logs internes exploitables --- ## Pattern : Token à usage unique — génération, hash et invalidation atomique - Objectif : standardiser la création et la consommation de tokens sensibles sans stocker de secret brut en base. - Contexte : invitation, reset de mot de passe, vérification d'email, lien magique ou tout token one-shot. - Quand l'utiliser : pour tout token à usage unique transmis à l'utilisateur. - Quand l'éviter : sessions longues ou secrets devant être relus en clair côté serveur. - Avantage : - réduit l'impact d'une fuite de base - garde des tokens URL-safe - favorise une consommation atomique et réutilisable - Limites / vigilance : - la consommation doit rester atomique - la politique d'expiration doit être explicite - Validé le : 16-03-2026 - Contexte technique : Node.js `crypto` / Prisma / email ou URL signée ### Implémentation (exemple minimal) ```txt - générer le token avec `crypto.randomBytes(32).toString("base64url")` - stocker uniquement le hash SHA-256 du token en base - transmettre le token brut uniquement via URL ou email - recalculer le hash côté serveur lors de la consommation - invalider le token dans une transaction atomique après usage ``` ### Checklist - Token brut jamais persisté en base - Hash recalculé côté serveur pour la vérification - Expiration explicite - Invalidation atomique après consommation --- ## Pattern : Autorisation interne minimale sans RBAC complet - Objectif : sécuriser une capacité interne sensible sans ouvrir trop tôt un chantier RBAC complet. - Contexte : application avec peu de rôles, besoin ponctuel d'une capacité admin ou opérateur clairement identifiée. - Quand l'utiliser : quand une story métier demande un pouvoir interne limité mais réel. - Quand l'éviter : si les permissions deviennent nombreuses, hiérarchiques ou contextuelles. - Avantage : - sécurisation rapide et lisible d'une capacité sensible - source de vérité backend explicite - chemin d'évolution propre vers un RBAC plus complet - Limites / vigilance : - ne pas laisser proliférer des rôles ad hoc non gouvernés - ne remplace pas un vrai modèle de permissions si le domaine grossit - Validé le : 10-03-2026 - Contexte technique : NestJS / auth par session ou JWT / API métier interne ### Implémentation (exemple minimal) ```txt - introduire un enum de rôle minimal côté backend (ex. USER | ADMIN) - propager ce rôle dans la session ou le token d'auth - créer un décorateur + guard dédiés pour la capacité sensible - interdire les booléens front, emails hardcodés ou `if` dispersés dans les contrôleurs ``` ### Checklist - Le rôle vit dans la source de vérité backend - Le rôle est propagé dans le mécanisme d'auth existant - Les endpoints sensibles passent par un guard dédié - Aucun contrôle d'accès critique n'est piloté par le front - Le passage à RBAC reste possible sans casser le contrat existant --- ## Pattern : Opérations auth sensibles — atomiques, idempotentes et cohérentes - Objectif : garantir que les opérations multi-étapes auth (reset, logout, révocation) ne laissent jamais un état incohérent. - Contexte : tout flux auth qui combine plusieurs writes : hash de mot de passe, invalidation de token, suppression de session. - Quand l'utiliser : systématiquement sur toute opération qui touche plusieurs tables auth en séquence. - Quand l'éviter : opérations de lecture pure. - Avantage : - pas de token valide après reset de mot de passe si l'opération est interrompue - suppression de session idempotente (P2025 absorbé silencieusement) - comportement prévisible même en cas de retry ou de concurrence - Limites / vigilance : - `$transaction` Prisma ne couvre pas les effets de bord réseau (email, cookies) — ces étapes restent hors transaction - Validé le : 16-03-2026 - Contexte technique : Node.js / Prisma / auth par session ou token ### Implémentation (exemple minimal) ```typescript // consumePasswordReset — atomique dans une transaction await prisma.$transaction([ prisma.passwordResetToken.update({ where: { tokenHash }, data: { consumedAt: new Date() }, }), prisma.user.update({ where: { id: userId }, data: { passwordHash: newHash }, }), prisma.session.deleteMany({ where: { userId } }), ]); // Suppression de session — idempotente (P2025 absorbé) try { await prisma.session.delete({ where: { sessionToken } }); } catch (err) { if (err?.code !== 'P2025') throw err; // session déjà supprimée → OK } ``` ### Checklist - [ ] Toute opération hash + update + delete dans une `$transaction` - [ ] `P2025` absorbé silencieusement sur les suppressions de session - [ ] Effets de bord hors transaction documentés (cookie, email) - [ ] Tests couvrant le cas d'une session déjà expirée --- ## Pattern : Scope minimal du cookie refresh token - Objectif : limiter l'exposition du cookie refresh token au strict minimum. - Contexte : migration d'un stockage localStorage vers cookies httpOnly pour les tokens d'authentification. - Quand l'utiliser : dès qu'un refresh token est transmis via cookie. - Quand l'éviter : jamais. - Avantage : - réduit la surface d'attaque — le cookie ne voyage qu'avec les requêtes de refresh - évite l'envoi inutile du refresh token sur les endpoints auth (login, password, invitations) - Limites / vigilance : - vérifier que le path est compatible avec le routing réel de l'endpoint de refresh - Validé le : 08-04-2026 - Contexte technique : auth / cookies httpOnly — RL799_V2 ### Règle - Le cookie `refresh_token` doit avoir `Path=/api/auth/refresh` (pas `/api/auth`). Seul l'endpoint de refresh a besoin de recevoir ce cookie. - Plus le path est large, plus la surface d'attaque est grande (le cookie voyage avec chaque requête matchant le path). ### Checklist - [ ] `Path=/api/auth/refresh` sur le cookie refresh token - [ ] `Path=/` uniquement pour l'access token (nécessaire sur toutes les routes API) - [ ] Vérifier que l'endpoint de refresh reçoit bien le cookie après changement de path --- ## Pattern : Distinction stricte 401 vs 403 - Objectif : permettre au frontend de réagir correctement aux erreurs d'authentification et d'autorisation. - Contexte : helper centralisé `requireRoleAccess` ou équivalent. - Quand l'utiliser : systématiquement sur tout helper d'authentification/autorisation. - Quand l'éviter : jamais. - Avantage : - le frontend peut distinguer "session expirée → redirection login" de "permission manquante → message accès refusé" - debug plus rapide en production - Limites / vigilance : - la distinction doit être cohérente sur tous les endpoints — ne pas retourner 403 pour un token absent sur certains services - Validé le : 08-04-2026 - Contexte technique : auth / RBAC — RL799_V2 ### Règle Le helper `requireRoleAccess` doit retourner : - **401 UNAUTHORIZED** : token absent, expiré, malformé, ou email manquant dans le payload - **403 FORBIDDEN** : token valide mais rôle non autorisé pour la ressource ### Checklist - [ ] 401 pour tout problème de token (absent, expiré, malformé) - [ ] 403 uniquement quand le token est valide mais le rôle insuffisant - [ ] Frontend redirige vers `/login` sur 401, affiche "accès refusé" sur 403 ### Complément — 403 vs 404 sur ressource existante non autorisée (oracle d'existence) Sur une ressource cloisonnée par appartenance (colonne, tenant, propriétaire), le choix 403 vs 404 est lui-même un risque d'énumération. Si l'ordre des contrôles est « ressource introuvable → 404 » PUIS « non autorisé → 403 », un attaquant distingue « la ressource existe mais pas pour moi » (403) de « n'existe pas » (404) → énumération. - Pour les ressources cloisonnées par appartenance, renvoyer **404 uniforme** dans le chemin non-privilégié (introuvable ET hors-périmètre confondus). - Réserver le **403** aux cas où l'existence de la ressource est déjà publique. - Trade-off : erreurs client moins précises. Pertinence proportionnelle à la prévisibilité des IDs (négligeable sur UUID v4, réelle sur IDs séquentiels). - Cas vécu : consultation d'instruction hors-colonne RL799 → bascule 403 → 404. --- ## Pattern : Dernier admin actif non supprimable + auto-action admin encadrée - Objectif : préserver l'accès administratif global et éviter les auto-actions destructrices. - Contexte : endpoints d'administration utilisateurs/rôles. - Quand l'utiliser : toute action de suppression, désactivation, rétrogradation d'un compte admin. - Quand l'éviter : jamais. ### Règle - Interdire toute action qui laisserait le système sans admin actif. - Encadrer les auto-actions admin (self-disable, self-demote, self-delete) avec règles explicites. ### Checklist - Vérification atomique "reste au moins un admin actif". - Codes d'erreur explicites (`LAST_ADMIN_LOCKOUT`, `SELF_ACTION_FORBIDDEN` ou équivalent). - Test dédié pour chaque cas limite. - Validé le : 17-04-2026 - Contexte technique : auth / RBAC admin — RL799_V2 --- ## Pattern : Distinguer audit best-effort et audit régalien - Objectif : expliciter quelles écritures d'audit sont non-bloquantes vs bloquantes. - Contexte : endpoints sensibles avec journalisation. - Quand l'utiliser : toute opération ayant une exigence de traçabilité. - Quand l'éviter : jamais. ### Règle - Audit best-effort : ne bloque pas l'opération métier principale. - Audit régalien/traçabilité critique : fait partie de la transaction logique et bloque en cas d'échec. ### Checklist - Classification explicite de chaque événement d'audit. - Politique d'échec documentée par endpoint. - Tests de comportement en cas de panne du canal d'audit. - Validé le : 17-04-2026 - Contexte technique : auth / audit — RL799_V2 --- ## Pattern : Consume single-use = pas de session implicite, force re-login - Objectif : éviter qu'un endpoint qui consume un magic link (invitation, reset password) émette une session implicite — défense en profondeur contre l'interception d'email. - Contexte : flows magic-link qui aboutissent à un `consume` (set du nouveau mot de passe ou activation du compte). - Quand l'utiliser : tous les flows single-use qui touchent au cycle d'authentification. - Quand l'éviter : magic-link "passwordless" pur (sans étape de saisie de mot de passe) où le link EST le facteur d'auth. - Avantage : - un attaquant qui intercepte le mail obtient un consume, pas une session - cohérence cross-flow : un seul pattern post-consume, surface d'audit réduite - factorisation page consume facilitée (les modes diffèrent par wording, pas par flow) - Limites / vigilance : - friction perçue d'un re-login → minime, l'utilisateur vient de saisir son mot de passe - Validé le : 28-04-2026 - Contexte technique : auth / magic-link — RL799_V2 ### Règle Réponse minimale post-consume : `{ data: { ok: true, email } }`. Pas de cookie JWT, pas d'access token. Le frontend redirige vers `/login?email=…` avec bouton "Se connecter" bien placé sur la page de succès. ### Anti-pattern - "Auto-login pour invitation, re-login pour reset" sous prétexte UX — divergence asymétrique = anti-pattern de sécurité - Cookie JWT émis avant que l'utilisateur ait validé qu'il connaît son nouveau mot de passe --- ## Pattern : Magic-link consume — mécanique partagée sans fusion table - Objectif : factoriser la mécanique sécu d'un consume single-use (hash SHA-256, transaction atomique, révocation refresh tokens) sans fusionner les modèles DB des deux domaines. - Contexte : projet avec deux flows partageant la même mécanique (invitation magic-link + reset password) mais des objets métier différents (invitation = audit riche, reset = purement technique). - Quand l'utiliser : factorisation au niveau **service**, pas au niveau table. - Quand l'éviter : un seul flow magic-link dans le projet — pas de factorisation prématurée. - Avantage : - audit historique riche pour les invitations (statut traçable) - transaction atomique partagée (rotation/consume) - rate limiter dédié par endpoint sans pollution cross-domaine - Limites / vigilance : - duplication contrôlée des modèles DB (acceptable — chaque domaine a son cycle de vie) - Validé le : 28-04-2026 - Contexte technique : auth / magic-link / Prisma — RL799_V2 ### Règles d'or 1. **Tokens stockés en hash SHA-256 uniquement** (`tokenHash @unique`). Le raw token n'est transmis que dans l'URL email, jamais persisté. Helper `hashXxxToken(raw: string): string` réutilisable côté service ET tests. 2. **PK technique `id String @id @default(uuid())`**, pas le token comme PK. Évite le couplage PK/sécurité, permet la rotation d'un token sans créer une nouvelle row. 3. **Transaction atomique consume** : - Lock implicite via `findUnique({ where: { tokenHash }, include: { user } })` - Check `status === 'active'`, `consumedAt === null`, `revokedAt === null`, `expiresAt > now`, `user.isActive === true` - UPDATE token `status='consumed', consumedAt=now()` - UPDATE user (password si applicable) - UPDATE refresh_tokens `revokedAt=now()` (force re-login propre) - UPDATE autres tokens actifs `status='revoked'` (anti-réutilisation) - INSERT audit dans la transaction (atomicité stricte) 4. **Retour discriminé** : ```typescript export type ConsumeResult = | { ok: true; userId: string; email: string } | { ok: false; reason: 'NOT_FOUND' | 'ALREADY_USED' | 'EXPIRED' | 'REVOKED' | 'USER_INACTIVE' }; ``` Le frontend ne connaît qu'un code générique côté UX (`INVALID_X_TOKEN`) pour ne pas exposer d'oracle. 5. **Rate limiter dédié par endpoint** : - `validate` : 30 req / 15 min / IP (oracle d'énumération) - `consume` : 10 req / 15 min / IP (modifie le mot de passe) - `resend` (admin) : 20 req / heure / admin 6. **Helpers transverses** : `revokeAndIssueXxx(input)` en transaction unique (couplé à un index unique partiel `WHERE status='active'`), `revokeXxxForUser(userId)` (appelé par `setUserActive(false)`), `getLatestXxxByUserIds(userIds[])` batch (anti N+1). --- ## Pattern : Sentinelle non-hashable pour user en attente de mot de passe - Objectif : éviter à la fois un `password: String?` nullable qui casse les chemins login/test/audit qui font tous des `select: { password }`, et un appel scrypt inutile (~100 ms par user invité). - Contexte : user créé via invitation qui n'a pas encore défini son mot de passe. - Quand l'utiliser : tout flow d'invitation où le user existe en base avant le set du password. - Quand l'éviter : projet où le user n'est créé qu'au consume du magic link (pas de placeholder nécessaire). - Avantage : - le champ `password` reste `NOT NULL` en DB → aucun chemin code ne casse - `verifyPassword` détecte le préfixe en early-return → 0 ms scrypt - garantie cryptographique (pas conventionnelle) : le préfixe `!` est absent d'une sortie hex - Limites / vigilance : - la sentinelle est lisible en clair par un admin DB — acceptable car c'est un placeholder identifié, pas un secret - Validé le : 28-04-2026 - Contexte technique : auth / scrypt — RL799_V2 ### Implémentation ```typescript // services/.../inviteService.ts const buildInvitedPlaceholderPassword = (): string => `!INVITED_PENDING_${crypto.randomUUID()}`; await prisma.user.create({ data: { email, password: buildInvitedPlaceholderPassword(), ... }, }); // lib/passwords.ts export const verifyPassword = (password: string, stored: StoredPassword): boolean => { if (typeof stored !== 'string' || typeof password !== 'string') return false; if (stored.startsWith('!')) return false; // placeholder réservé, jamais matchable // …logique scrypt habituelle }; ``` ### Checklist - [ ] Préfixe `!` (caractère **garanti** absent de l'alphabet hex `0-9a-f`) - [ ] UUID embarqué pour l'unicité par user (utile pour debug audit, pas un secret) - [ ] Test : `verifyPassword('anything', '!INVITED_PENDING_xxx') === false` - [ ] AC dédié : `verifyPassword` rejette en 0 ms observable (pas d'appel scrypt) --- ## Pattern : TTL court + bouton resend admin > TTL longue - Objectif : minimiser la fenêtre d'exposition d'un token sensible sans dégrader l'UX dans les cas marginaux (user en vacances). - Contexte : tokens d'authentification sensibles (invitation, reset password) où la tentation est d'allonger la TTL pour couvrir les cas de longue absence. - Quand l'utiliser : tout token sensible avec un canal admin disponible pour relancer. - Quand l'éviter : tokens où le resend est impossible (signed URLs publiques sans admin). - Avantage : - surface d'attaque réduite (token intercepté n'est utilisable que pendant la TTL courte) - granularité opérationnelle : l'admin trace dans l'audit qui demande un resend - invariant "1 active par user" reste applicable (le resend révoque l'ancien et émet un nouveau) - Limites / vigilance : - rate limiter sur le bouton resend (20/h/admin) pour éviter le spam involontaire - pas d'extension d'`expiresAt` au resend — émettre un nouveau token, pas patcher l'ancien - Validé le : 28-04-2026 - Contexte technique : auth / magic-link — RL799_V2 ### Règles d'or - TTL en constante explicite côté service (`7 * 24 * 60 * 60 * 1000`), pas en magic number éparpillé - Fenêtres recommandées : invitation ≤ 7 jours, reset password ≤ 24 h - Audit `xxx.resend` sur le bouton admin + audit `xxx.revoke` (cohérence avec resend qui révoque l'ancien) - AC dédié : `expiresAt - createdAt ≈ N * 24h ± 1s` (tolérance fixture) --- ## Pattern : Hook `setUserActive(false)` → revoke side-tokens - Objectif : éviter qu'un user désactivé puisse continuer à consommer un magic link reçu juste avant la désactivation et reprendre la main. - Contexte : opération admin de désactivation user dans un projet avec tokens secondaires actifs (refresh tokens, invitations, futurs reset password tokens). - Quand l'utiliser : tout endpoint qui transitionne `user.isActive` de `true` à `false`. - Quand l'éviter : si la désactivation est purement métier (suspension UI) sans portée auth. - Avantage : - défense en profondeur (le checker du consume vérifie aussi `user.isActive` — ceinture + bretelles) - les tokens orphelins ne survivent pas à la désactivation - Limites / vigilance : - best-effort tracé : les revokes ne doivent pas bloquer la désactivation (l'admin attend un retour immédiat) - Validé le : 28-04-2026 - Contexte technique : auth / lifecycle user — RL799_V2 ### Implémentation ```typescript if (!body.isActive) { try { await revokeAllRefreshTokensForUser(userId); } catch (err) { console.error('[admin.users] refresh token revoke failed', err); refreshTokenWarning = '...'; } try { await revokeInvitationsForUser(userId); } catch (err) { console.error('[admin.users] invitation revoke failed', err); } // À ajouter quand pertinent : reset password tokens, OAuth states, etc. } ``` ### Règles d'or - **Best-effort tracé**, pas silencieux : `try { ... } catch { console.error(...) }` - Warning UX en cas d'échec partiel : la réponse JSON peut contenir un champ optionnel `warning` que le frontend affiche - **Double protection consume** : le checker du consume vérifie également `user.isActive === true` — même si le revoke échoue, l'user désactivé ne peut pas consommer - AC dédié : "user actif avec invitation pending → admin désactive → consume échoue avec INVALID_TOKEN, pas de session créée" --- ## Pattern : Magic link "URL clean" — token signé HMAC + `history.replaceState` - Objectif : ouvrir une page web qui pré-charge un contexte serveur (soireeId, eventId, inviteId) sans que l'identifiant ne reste visible dans l'URL navigable. - Contexte : mail (convocation, invitation, RSVP) avec un bouton qui mène vers une landing page PWA. On veut éviter forward, capture, indexation involontaire. - Quand l'utiliser : magic links publics vers une page qui pré-charge un contexte sensible. - Quand l'éviter : magic links d'authentification membre — utiliser les patterns auth classiques (refresh + access httpOnly). - Avantage : - URL visible côté utilisateur ne contient pas l'identifiant - HMAC garantit l'intégrité (custom claim `purpose` pour rejeter un token signé pour un autre usage) - `sessionStorage` plutôt que `localStorage` → contexte meurt avec l'onglet - Limites / vigilance : - `algorithms: ['HS256']` imposé côté `jwt.verify` pour bloquer les "alg: none" attacks - sécurité dépendante de l'isolation cryptographique du secret (cf. pattern dérivation HMAC) - Validé le : 30-04-2026 - Contexte technique : Node.js crypto / Vue / Vite PWA — RL799_V2 ### Architecture ``` Mail HTML ↓ Bouton (GET https://app/visit?t=) Landing PWA /visit ↓ JS lit le token, POST /api/.../redeem { token } ↓ Backend valide HMAC + extrait contextId, retourne metadata ↓ JS stocke contextId en sessionStorage ↓ history.replaceState(null, '', '/inscription') ↓ router.replace() pour synchroniser le router SPA Page d'inscription (URL clean) ``` ### Backend — signature ```typescript import jwt from 'jsonwebtoken'; export function signAccessToken(contextId: string, expiresAt: Date): string { const expSeconds = Math.floor(expiresAt.getTime() / 1000); return jwt.sign( { sub: contextId, purpose: 'rsvp-v1', exp: expSeconds }, getDerivedSecret(), { algorithm: 'HS256' }, ); } export function redeemAccessToken(token: string): | { ok: true; contextId: string } | { ok: false; reason: 'expired' | 'invalid' } { try { const decoded = jwt.verify(token, getDerivedSecret(), { algorithms: ['HS256'], }) as { sub?: string; purpose?: string }; if (decoded.purpose !== 'rsvp-v1') return { ok: false, reason: 'invalid' }; if (typeof decoded.sub !== 'string') return { ok: false, reason: 'invalid' }; return { ok: true, contextId: decoded.sub }; } catch (err) { if (err instanceof jwt.TokenExpiredError) return { ok: false, reason: 'expired' }; return { ok: false, reason: 'invalid' }; } } ``` ### Frontend — landing minimaliste ```typescript onMounted(async () => { const token = route.query.t as string; if (!token) return showError('Lien incomplet'); const result = await redeemToken(token); saveSessionContext(result); // sessionStorage if (typeof window !== 'undefined' && window.history?.replaceState) { window.history.replaceState(null, '', '/inscription'); } await router.replace({ name: 'inscription' }); }); ``` ### Choix d'expiration - Magic link auth : court (15 min – 24 h), token consommable une fois - RSVP événement : long (jusqu'à la date) - Invitation one-shot : moyen (7-30 jours), invalidable à l'usage --- ## Pattern : Isolation cryptographique — secret dérivé via HMAC - Objectif : signer plusieurs types de tokens isolés cryptographiquement sans ajouter une nouvelle env var par usage. - Contexte : projet avec un `JWT_SECRET` racine (auth membre) qui doit signer un autre type de token (magic link, RSVP, webhook) sans cross-domain attack possible. - Quand l'utiliser : besoin d'un secret de signature isolé sans nouvelle clé à provisionner et à tourner. - Quand l'éviter : si le projet supporte déjà un système de gestion de clés multiples (KMS, Vault) — utiliser le mécanisme natif. - Avantage : - une seule env var racine à provisionner et tourner - HMAC est one-way : un attaquant qui obtient le secret dérivé ne peut pas remonter au racine - reproductible : `(JWT_SECRET, purpose)` → même secret, pas d'état à persister - versionnable via le purpose (`-v1`, `-v2`) : rotation possible en bumpant le purpose - Limites / vigilance : - n'est PAS un substitut à la rotation de clés : si `JWT_SECRET` est compromis, tous les secrets dérivés le sont aussi - HKDF est plus rigoureux pour la dérivation formelle ; pour un simple isolement d'usage, `HMAC(secret, purpose)` suffit - Validé le : 30-04-2026 - Contexte technique : Node.js crypto — RL799_V2 ### Implémentation ```typescript import { createHmac } from 'node:crypto'; const TOKEN_PURPOSE = 'magic-link-v1'; let cachedSecret: Buffer | null = null; function getDerivedSecret(): Buffer { if (cachedSecret) return cachedSecret; const root = process.env.JWT_SECRET; if (!root) throw new Error('JWT_SECRET requis'); cachedSecret = createHmac('sha256', root).update(TOKEN_PURPOSE).digest(); return cachedSecret; } ``` ### Test d'isolation ```typescript test('rejette un token signé avec un autre purpose', async () => { const tokenAsMember = jwt.sign( { sub: 'x' }, process.env.JWT_SECRET!, { algorithm: 'HS256', expiresIn: '15m' }, ); const result = redeemToken(tokenAsMember); expect(result.ok).toBe(false); }); ``` ### Applications - Tokens magic link / RSVP / invitation - Signature de webhooks sortants - Tokens d'unsubscribe email (lien direct sans login) - Tokens d'accès aux ressources publiques limitées dans le temps --- ## Pattern : jose — whitelister explicitement l'algorithme (signature ET chiffrement) - Objectif : empêcher les attaques de confusion d'algorithme en figeant l'algo accepté à la vérification/déchiffrement, et non en se fiant à ce que la clé permet. - Contexte : tout module qui vérifie (`jwtVerify`) ou déchiffre (`jwtDecrypt`) des tokens/sessions avec `jose` — OIDC, JWE de session BFF, tokens internes. - Quand l'utiliser : à chaque appel `jose` qui consomme un token signé ou chiffré. - Quand l'éviter : jamais sur une surface qui consomme des tokens externes ou attaquables. - Avantage : - bloque la confusion d'algorithme (un attaquant ne peut pas forcer un algo plus faible présent dans le JWKS ou supporté par la clé) - rend explicite le contrat cryptographique attendu de l'émetteur - Limites / vigilance : - l'`alg` est un contrôle à part entière — `iss`/`aud`/`exp` ne le couvrent pas - garder la whitelist synchronisée avec ce que l'IdP/l'émetteur produit réellement - Validé le : 13-06-2026 - Contexte technique : jose / OIDC / JWE — RL799_V2 ### Règle `jwtVerify(token, jwks, { issuer, audience })` SANS `algorithms: [...]` accepte tout algo présent dans le JWKS → confusion d'algorithme. Idem `jwtDecrypt(jwe, key)` sans `keyManagementAlgorithms`/`contentEncryptionAlgorithms` accepte tout algo supporté par jose avec la clé fournie. - À chaque `jwtVerify` : passer `algorithms: []` (Keycloak = `['RS256']`). - À chaque `jwtDecrypt` : passer `keyManagementAlgorithms` + `contentEncryptionAlgorithms` figés sur ce que l'émetteur produit (ex. `['dir']` + `['A256GCM']`). - L'`alg` est le 4ᵉ contrôle obligatoire après `iss`/`aud`/`exp`. S'applique à tout (dé)chiffrement de tokens/sessions, pas seulement OIDC. ### Checklist - [ ] `algorithms: [...]` explicite sur chaque `jwtVerify` - [ ] `keyManagementAlgorithms` + `contentEncryptionAlgorithms` explicites sur chaque `jwtDecrypt` - [ ] Whitelist alignée sur l'émetteur réel - [ ] Test : un token signé/chiffré avec un autre algo est rejeté --- ## Pattern : Membrane d'auth fédérée en coexistence — point de jonction unique - Objectif : greffer un 2ᵉ système d'authentification (ex. Keycloak OIDC) à côté de l'auth maison sans la modifier, de façon rollback-safe et sans disperser l'AuthN/AuthZ. - Contexte : intégration d'un IdP fédéré en coexistence avec une auth existante, livrée par lots. - Quand l'utiliser : toute introduction d'un second mécanisme d'auth amené à coexister. - Quand l'éviter : remplacement immédiat sans phase de coexistence (cutover direct). - Avantage : - tout le code en aval ignore l'existence du 2ᵉ système (NFR « AuthN/AuthZ non dispersée ») - rollback par simple flag (zéro delta comportemental flag off) - invariant d'identité fédérée protège contre l'escalade de privilèges par claim forgé - Limites / vigilance : - la jonction devient async (chemin fédéré = déchiffrement + JWKS + DB) → propager `await` à tous les call sites - flag d'activation à lire à chaque requête, jamais memoizé - Validé le : 14-06-2026 - Contexte technique : auth fédérée / OIDC / Keycloak BFF — RL799_V2 ### Règles d'or 1. **Un seul point de jonction** : `resolveAuthPayload(request)` traduit token → contexte métier `{ userId, email, role, offices }`. Tout le reste du code ignore le 2ᵉ système. 2. **Discrimination par SOURCE, jamais par inspection du contenu** : cookie/header A → chemin maison ; cookie B → chemin fédéré. Si les deux coexistent, le plus conservateur (maison) GAGNE. 3. **Flag d'activation fail-closed STRICT** : ON ssi `process.env.FLAG === 'true'` (toute autre valeur = OFF), lu à CHAQUE requête (jamais memoizé — sinon les tests qui togglent via `vi.stubEnv` deviennent ordre-dépendants ; seule la CONFIG est lazy-memoizée). 4. **Invariant sacré de l'identité fédérée** : le validateur de token ne sort QUE l'identité (`sub` + `email` informatif), JAMAIS un claim d'autorisation (`realm_access.roles`, grade, office). `role`/`offices` dérivés EXCLUSIVEMENT de la DB locale. Test d'invariant obligatoire : forger un token avec `realm_access.roles: ['admin']` et vérifier qu'il n'accorde RIEN (403 sur route admin). 5. **Migration des call sites async** : si la jonction rend les helpers d'auth async, propager `await` à TOUS les call sites par codemod mécanique guidé par `tsc`, et prouver le zéro-delta par la suite complète flag-off verte. - Cas vécu : RL799 K1.1 (`authHelpers.ts::resolveAuthPayload`, 140 call sites migrés, test:api flag off vert). --- ## Pattern : Frontière de session BFF stateless — JWE `dir`+`A256GCM`, fail-closed - Objectif : porter une session contenant des tokens (OIDC ou autres) dans un cookie httpOnly sans store serveur (pas de Redis/table). - Contexte : archi BFF où les tokens vivent côté serveur, chiffrés dans un cookie, jamais exposés au JS. - Quand l'utiliser : session stateless chiffrée portée par cookie. - Quand l'éviter : sessions à store serveur (Redis/table) déjà en place, ou besoin de révocation immédiate côté serveur. - Avantage : - équivalent iron-session sans dépendance supplémentaire si `jose` est déjà là - cookie altéré → échec de déchiffrement (AEAD), expiration vérifiée gratuitement par `jwtDecrypt` - frontière isolée : un seul module manipule les tokens bruts - Limites / vigilance : - taille du cookie à surveiller (< 4096 octets) - secret dédié durci, validé strictement - Validé le : 14-06-2026 - Contexte technique : jose / cookie httpOnly / BFF — RL799_V2 ### Règles d'or 1. **Un SEUL module** (dé)chiffre et manipule les tokens bruts — vérifiable par `grep "from 'jose'"` limité à ce module + le validateur. Tout le reste reçoit du déjà-déchiffré. 2. **JWE `alg: 'dir'` + `enc: 'A256GCM'`** (AEAD) avec `setIssuedAt`/`setExpirationTime` → l'`exp` du JWE donne l'expiration de session. 3. **Whitelist explicite des algos au déchiffrement** (`keyManagementAlgorithms: ['dir']`, `contentEncryptionAlgorithms: ['A256GCM']`) — cf. [pattern jose — whitelist d'algorithme](#pattern-jose-whitelist-algorithme). 4. **Secret DÉDIÉ par usage** (jamais le secret de chiffrement-au-repos), validé `/^[0-9a-f]{64}$/i` — PAS seulement `length === 64` (64 chars non-hex → Buffer < 32 bytes → erreur cryptique ; `A256GCM dir` exige exactement 32 bytes). Fail-fast avec hint de génération, lu lazy NON memoizé (testabilité). 5. **Fail-closed asymétrique** : la clé est obtenue AVANT le try/catch (secret absent/malformé = erreur OPS → doit TRAVERSER → 500), MAIS toute erreur de déchiffrement/format/exp dans le try → `null` (cookie invalide, pas une panne) + log du TYPE d'échec (`decrypt_failed|expired|malformed`) SANS le contenu. 6. **La frontière est une LIB** : elle ne fabrique jamais de `Response` — la couche jonction traduit `null` en 401. 7. Logger via le logger structuré du projet (pino), pas `console.*`. - Cas vécu : RL799 K1.2 `lib/keycloak/session.ts` (cookie ~3 ko < 4096, TTL 8 h aligné Max-Age + exp JWE). --- ## Pattern : Pont d'identité fédérée — colonne `idpSub` nullable + finder fail-safe - Objectif : relier un identifiant opaque d'IdP (le `sub` OIDC) à un `User` local, en posant d'abord l'interface (stub) puis la résolution réelle quand le découpage en lots l'impose. - Contexte : série « membrane d'auth fédérée » — liaison `sub IdP → User` local sans toucher la couche consommatrice. - Quand l'utiliser : besoin d'un mapping identité externe → user local, livré par lots. - Quand l'éviter : pas de fédération d'identité, ou liaison déjà existante. - Avantage : - migration purement additive, back-fill incrémental possible - shape figé par la jonction → toute divergence casse la jonction au typecheck - aucune fuite de l'`idpSub` (clé de liaison interne) - Limites / vigilance : - colonne nullable tant que le cutover n'est pas complet (NOT NULL seulement après) - le finder ne décide pas de l'accès — il remonte l'état brut - Validé le : 14-06-2026 - Contexte technique : Prisma / OIDC / repository — RL799_V2 ### Règles d'or 1. **Migration purement additive** : `ADD COLUMN TEXT` + `CREATE UNIQUE INDEX`, **nullable, pas de NOT NULL, pas de DEFAULT, pas de back-fill**. Les comptes pré-fédération restent NULL (Postgres tolère plusieurs NULL sous un index `@unique` → back-fill incrémental). Le NOT NULL viendrait seulement après cutover complet. 2. **Le finder vit au repository** (`route → service → repository`), retourne EXACTEMENT le shape figé par la couche jonction (ex. `{ id, email, role, isActive }`), via `select` minimal — jamais le mapper complet du record. 3. **Fail-safe** : try/catch → `null` (une panne DB ne doit pas 500 le chemin authentifié). 4. **Ne filtre PAS sur l'état actif** : retourne `isActive` tel quel — la décision d'accepter/rejeter appartient à la jonction (source unique). 5. **Sens d'import** : la lib (subResolver) importe la VALEUR du finder ; le repository importe le TYPE du contrat en `import type` (effacé à la compilation → pas de cycle runtime). 6. **No-leak** : l'`idpSub` est une clé de liaison interne — jamais en réponse API, jamais loggé. Le `userId` exposé en aval est l'`id` LOCAL, jamais le `sub`. 7. **Prouver la levée du stub par un test E2E qui n'injecte PAS** le resolver de test (laisse le resolver de prod réel) : poser un `sub` sur un user de seed, forger un token, flag on → résolution DB réelle. Sans ce test, on prouve seulement le finder, pas le câblage. - Cas vécu : RL799 K1.3 `findUserByKeycloakSub` + `User.keycloakSub`. --- ## Pattern : Greffe d'effet de bord externe best-effort sur une opération régalienne - Objectif : déclencher un effet de bord EXTERNE non-critique (provisionner une identité IdP, notifier un tiers) depuis une mutation locale CRITIQUE sans jamais coupler leur succès. - Contexte : mutation régalienne (créer un membre) qui doit déclencher un appel externe non-bloquant. - Quand l'utiliser : tout effet de bord externe non-critique greffé sur une opération locale qui ne doit jamais échouer. - Quand l'éviter : effet de bord faisant partie de la transaction logique (audit régalien — cf. [pattern audit best-effort vs régalien](#pattern-auth-audit-best-effort-vs-regalien)). - Avantage : - l'opération locale réussit même si l'effet de bord échoue - reprovisionnable au rejeu (idempotent), zéro doublon - rollback par flag (zéro appel réseau flag off) - Limites / vigilance : - timeout interne obligatoire pour ne pas retarder la réponse régalienne - service account dédié à périmètre minimal, secrets jamais loggés - Validé le : 15-06-2026 - Contexte technique : auth fédérée / provisioning IdP / fetch — RL799_V2 ### Règles d'or 1. **Architecture en couches stricte** : `clientBas-niveau` (HTTP/fetch isolé, zéro logique métier) ← `serviceOrchestration` (chercher-avant-créer, fail-safe, écriture DB) ← greffe dans le service régalien. Jamais d'appel HTTP direct depuis le service métier. 2. **Fail-safe TOTAL au service** : try/catch englobant → retourne `null` (jamais de throw vers l'appelant régalien), log + audit du statut. L'entité « non-provisionnée » est reprovisionnable au rejeu. 3. **Idempotent par chercher-avant-créer** : interroger l'externe par clé naturelle (email `exact=true`) avant de créer ; réutiliser l'existant. 4. **Timeout interne obligatoire** (`AbortController`, ex. 5 s) : un externe qui pend ne doit pas bloquer la réponse régalienne. Placer la greffe APRÈS les autres effets de bord prioritaires (ex. envoi de mail). 5. **Token machine-to-machine memoizé avec marge d'expiration** (client credentials), pas un fetch par appel. Service account DÉDIÉ à périmètre minimal (`manage-users`), distinct du client d'auth utilisateur. 6. **Minimisation des données** vers l'externe : body strictement limité à l'identité d'auth, zéro donnée métier sensible (invariant RGPD). Tester par assertion sur les clés exactes du payload. 7. **Flag-gated `=== 'true'` lu à chaque requête** : flag off = zéro appel réseau, zéro branche, zéro delta. 8. **Secrets jamais loggés** (token, client_secret) ; ne logger que des identifiants non-sensibles (email, userId, statut). - Cas vécu : RL799 K1.4 `provisionMemberIdentity` greffé sur `handleCreateMember`, fetch + token injectables pour tests (aucun IdP vivant en CI). --- ## Pattern : Refresh de session BFF 100 % serveur — signal `Set-Cookie` remonté à la frontière - Objectif : rafraîchir l'access token expiré côté serveur, dans le point de jonction d'auth, sans que la couche LIB (sans accès à la `Response`) ne fabrique elle-même le cookie. - Contexte : archi BFF où les tokens OIDC sont chiffrés dans un cookie httpOnly, jamais exposés au JS. - Quand l'utiliser : refresh d'une session BFF stateless portée par cookie. - Quand l'éviter : session à store serveur où la rotation se fait côté store. - Avantage : - la jonction reste une LIB (pas de fabrication de `Response`) - re-login transparent côté front quand le SSO IdP est encore actif - aucun token exposé au JS - Limites / vigilance : - la réécriture du cookie doit être généralisée (cf. risque rotation refresh token IdP) - convention d'unité epoch en SECONDES (OIDC) au recalcul de `expiresAt` - Validé le : 15-06-2026 - Contexte technique : openid-client / cookie httpOnly / BFF — RL799_V2 ### Règles d'or 1. **La jonction remonte un champ `sessionCookieToApply?: string`** dans son contexte de retour — le `Set-Cookie` du nouveau cookie chiffré, calculé après refresh réussi. La LIB ne fabrique pas de cookie, elle REMONTE un signal. 2. **Un handler de frontière l'attache** (`headers.append('Set-Cookie', ...)`). Point d'application : le handler d'HYDRATATION systématique post-login (`/me` / `/session`) appelé à chaque boot par les router guards. 3. **Marge de refresh anticipé** (~30 s) : rafraîchir un token qui expire dans < marge, pour couvrir le temps de traitement + dérive d'horloge. 4. **Fail-closed** : le helper de refresh retourne `null` sur tout échec (refresh token mort/révoqué, panne IdP), jamais de throw. La jonction mappe `null` → 401 code distinct (`SESSION_EXPIRED`) + purge du cookie zombie (clear immédiat, sinon le cookie mort reboucle). 5. **Convention d'unité epoch en SECONDES** (OIDC) au recalcul de `expiresAt` post-refresh — un mélange s/ms est un bug classique coûteux. 6. **Invariant minimisation** : après refresh, repasser par le validateur de token qui n'extrait QUE l'identité (sub/email). `role`/`permissions` viennent TOUJOURS de la DB. 7. **Distinguer deux codes 401** : « access token expiré mais rattrapable par refresh » (transparent, pas remonté au front) vs « session non rafraîchissable » (`SESSION_EXPIRED` → re-login transparent côté front). 8. **Hook de test injectable du grant** qui ENCAPSULE la résolution de config (discovery réseau) côté prod : un grant de test ne touche jamais le réseau. Tester sur le CODE DE PRODUCTION réel (jonction + handler `/me` + tokens RS256 forgés), pas sur des mocks. - Cas vécu : RL799 K1.5 `refreshKeycloakSession` + `resolveAuthPayload` + `sessionCookieToApply` consommé par `handleGetSession`, openid-client v6 `refreshTokenGrant`. --- ## Pattern : Cutover d'auth irréversible — isoler le point de non-retour hors du chemin auto - Objectif : livrer une bascule destructive (retrait d'un système d'auth legacy au profit d'un nouveau) en gardant tout réversible sauf les 2-3 actions vraiment irréversibles, isolées derrière une gate et un prérequis ops. - Contexte : migration destructive — retrait d'un système legacy, `ALTER COLUMN SET NOT NULL` après backfill, `DROP TABLE`. - Quand l'utiliser : toute migration où le rollback cesse d'être possible une fois exécutée. - Quand l'éviter : changements purement additifs et réversibles. - Avantage : - le code livré reste réversible via git tant que l'irréversible n'est pas exécuté - aucun `migrate deploy` ne franchit le point de non-retour par accident - gate GO/NO-GO + runbook documenté - Limites / vigilance : - prérequis ops hors-code = bloquant explicite, story marquée NON-MERGEABLE si non vérifiable par l'agent - vérifier en review les 3 risques d'un retrait d'auth - Validé le : 15-06-2026 - Contexte technique : migration destructive / Prisma / cutover — RL799_V2 ### Règles d'or 1. **Séparer le réversible de l'irréversible.** Tout le code (retrait des branches legacy, nouveaux chemins) est livré et vert — réversible via git. Les 2-3 actions VRAIMENT irréversibles (migration `NOT NULL`, `DROP TABLE`) sont placées en **migration DRAFT HORS du dossier que l'outil applique automatiquement** (ex. `docs/infra/cutover-migration-draft/migration.sql`, PAS `prisma/migrations/`). Le schéma déclaré reste réversible (`String?`). 2. **Gate bloquante AVANT l'irréversible** : un script lecture-seule (`cutover-check`) qui vérifie les pré-conditions (0 orphelin cassé par la contrainte) et émet GO/NO-GO. Ordre IMPOSÉ et documenté en runbook : prérequis ops → gate verte → migration → retrait final. Jamais inverser. 3. **Prérequis ops hors-code = bloquant documenté** : si la bascule dépend d'une capacité externe (config realm IdP, SMTP, service account) que l'agent NE PEUT PAS vérifier, marquer la story explicitement NON-MERGEABLE et le tracer. Ne pas présumer « code vert = prêt à merger ». 4. **Vérifier en review les 3 risques d'un retrait d'auth** : (a) tout hook/scaffolding de test sur le chemin d'auth est STRUCTURELLEMENT inerte en prod (call-site unique dans le setup de test, `null` au module-level, chargé seulement par le runner — grep exhaustif des call-sites) ; (b) retrait NET prouvé par grep 0-hit des primitives legacy (`createToken|verifyToken|verifyPassword`) en code de prod ; (c) le point de non-retour est bien hors du chemin auto. - Cas vécu : RL799 K1.8 cutover Keycloak — migration NOT NULL+DROP en draft hors-Prisma, gate `runCutoverGate`, prérequis FR10 realm bloquant, hook resolver de test inerte vérifié. --- ## Pattern : Surface publique authentifiée par token opaque (quick-link mail, sans login) - Objectif : exposer une surface publique non authentifiée par un token opaque (quick-link reçu par mail) en durcissant systématiquement contre l'énumération, le prefetch et la fuite de données. - Contexte : route accessible sans login via un token de mail (présence-sans-friction, instruction sans-friction, RSVP). - Quand l'utiliser : toute surface publique à token opaque. - Quand l'éviter : authentification membre classique — utiliser refresh + access httpOnly. - Avantage : - identité prouvée par possession du token, éligibilité re-vérifiée à chaque requête - pas d'oracle d'énumération (réponse neutre indistinguable) - GET read-only protège contre le prefetch (SafeLinks Outlook) - Limites / vigilance : - mapper de sortie dédié obligatoire (le mapper interne fuit des champs) - rate-limiter dédié à enregistrer dans le registre central de reset - Validé le : 23-06-2026 - Contexte technique : surface publique / token opaque / mail — RL799_V2 ### Checklist de durcissement (validée 2× sur RL799) 1. **Token opaque = identité, JAMAIS éligibilité.** Stocker `sha256(token)` en DB (jamais le clair), lookup par hash sur index unique. Le clair ne vit qu'en mémoire au dispatch. 2. **Garde partagée GET+POST** (`resolveDeliveryAndGuards`) en union discriminée `{ ok: true, ... } | { ok: false, code }` — un seul point de revalidation appelé par les deux verbes (DRY + cohérence). 3. **Re-valider l'éligibilité à CHAQUE requête** (membre actif + grade/colonne), pas seulement à l'émission du token — bloque la pollution après changement d'état. 4. **401 NEUTRE indistinguable** : token forgé ET inéligibilité membre → MÊME code + MÊME message (constante `NEUTRAL_TOKEN_MESSAGE`). Pas d'oracle d'énumération RGPD. La distinction est tracée UNIQUEMENT côté serveur (`logSecurityEvent`). À TESTER : `expect(messageMismatch).toBe(messageForged)`. 5. **Mapper de sortie DÉDIÉ minimal** — ne JAMAIS réutiliser le mapper interne (qui fuite `organizerId`/agrégat/id). À TESTER par clés EXACTES : `expect(Object.keys(data).sort()).toEqual([...])` + boucle sur les champs interdits. 6. **GET strictement read-only** (anti-prefetch SafeLinks Outlook) ; mutation seulement au POST (clic explicite). À TESTER : un GET n'écrit rien en DB. 7. **Rate-limiter IP dédié**, AJOUTÉ au registre central de reset (sinon flakiness inter-fichiers). 8. **Domaine d'erreur dédié** (pas de réutilisation d'un code d'un autre domaine public). 9. **Route fine** (export GET/POST → handler thin → service). Aucune logique dans la route. --- ## Pattern : RBAC dérivé d'une source de vérité — JWT enrichi + union au guard - Objectif : dériver une autorité (rôle/capacité) d'une source unique (mandat, relation) sans la dupliquer ni introduire de fenêtre d'incohérence. - Contexte : un rôle/accès doit dériver d'une entité (ex. `OfficerMandate`) sans devenir une source concurrente. - Quand l'utiliser : autorité dérivable d'une relation, mutation de la source rare. - Quand l'éviter : besoin de fraîcheur immédiate (la source doit se propager en < TTL du token). - Avantage : - NFR « pas de fenêtre d'incohérence » satisfaite par construction (source lue, jamais dupliquée comme rôle séparé) - cumul des autorités préservé - reste zéro-DB au guard - Limites / vigilance : - un changement de la source met jusqu'à la durée de vie du token (ex. 15 min) à se propager — acceptable si la mutation est rare - Validé le : 12-06-2026 - Contexte technique : auth / JWT / RBAC dérivé — RL799_V2 ### Règle NE PAS écrire le rôle dérivé dans la table user (duplication + fenêtre d'incohérence à gérer). À la place : 1. calculer les attributs dérivés au login/refresh via un helper unique, les injecter dans le JWT à côté du rôle de base ; 2. au guard, évaluer l'union `{rôle de base} ∪ {attributs dérivés}` — reste zéro-DB. Trade-off assumé : un changement de la source met jusqu'à la durée de vie du token à se propager. ### Stratégie de migration sans coupure (app live) Basculer par ADDITION : backfill source → enrichir le token en coexistence → guards en union sur-ensemble → réduire l'enum en DERNIER (le typecheck servant de filet d'exhaustivité). - Cas vécu : refonte RBAC RL799 (offices dérivés des mandats). ---