Triage et intégration des propositions backend du buffer 95_a_capitaliser.md (lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation remote antérieure (triage 2026-05-02). ~73 entrées intégrées sur knowledge/backend/, dont : - patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist - patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C), row réactivable, endpoint replace atomique, updateMany conditionnel, etc. - risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste, fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.) - patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests - compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...) - README patterns/risques mis à jour Doublons internes corrigés en relecture (suppression-champ .map() → general seul ; e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés. Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
52 KiB
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.mdpour 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)
{
"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)
- 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)
- 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)
- 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)
- 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 :
$transactionPrisma 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)
// 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 P2025absorbé 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_tokendoit avoirPath=/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/refreshsur le cookie refresh tokenPath=/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é
requireRoleAccessou é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
/loginsur 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_FORBIDDENou é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
-
Tokens stockés en hash SHA-256 uniquement (
tokenHash @unique). Le raw token n'est transmis que dans l'URL email, jamais persisté. HelperhashXxxToken(raw: string): stringréutilisable côté service ET tests. -
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. -
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)
- Lock implicite via
-
Retour discriminé :
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.
-
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
-
Helpers transverses :
revokeAndIssueXxx(input)en transaction unique (couplé à un index unique partielWHERE status='active'),revokeXxxForUser(userId)(appelé parsetUserActive(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 desselect: { 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
passwordresteNOT NULLen DB → aucun chemin code ne casse verifyPassworddétecte le préfixe en early-return → 0 ms scrypt- garantie cryptographique (pas conventionnelle) : le préfixe
!est absent d'une sortie hex
- le champ
- 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
// 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 hex0-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é :
verifyPasswordrejette 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'
expiresAtau 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.resendsur le bouton admin + auditxxx.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.isActivedetrueà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
- défense en profondeur (le checker du consume vérifie aussi
- 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
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
warningque 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
purposepour rejeter un token signé pour un autre usage) sessionStorageplutôt quelocalStorage→ contexte meurt avec l'onglet
- Limites / vigilance :
algorithms: ['HS256']imposé côtéjwt.verifypour 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=<token-signé>)
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
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
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_SECRETracine (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_SECRETest 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
- n'est PAS un substitut à la rotation de clés : si
- Validé le : 30-04-2026
- Contexte technique : Node.js crypto — RL799_V2
Implémentation
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
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 avecjose— OIDC, JWE de session BFF, tokens internes. - Quand l'utiliser : à chaque appel
josequi 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'
algest un contrôle à part entière —iss/aud/expne le couvrent pas - garder la whitelist synchronisée avec ce que l'IdP/l'émetteur produit réellement
- l'
- 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: passeralgorithms: [<algo émis par l'IdP>](Keycloak =['RS256']). - À chaque
jwtDecrypt: passerkeyManagementAlgorithms+contentEncryptionAlgorithmsfigés sur ce que l'émetteur produit (ex.['dir']+['A256GCM']). - L'
algest le 4ᵉ contrôle obligatoire aprèsiss/aud/exp. S'applique à tout (dé)chiffrement de tokens/sessions, pas seulement OIDC.
Checklist
algorithms: [...]explicite sur chaquejwtVerifykeyManagementAlgorithms+contentEncryptionAlgorithmsexplicites sur chaquejwtDecrypt- 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é
- la jonction devient async (chemin fédéré = déchiffrement + JWKS + DB) → propager
- Validé le : 14-06-2026
- Contexte technique : auth fédérée / OIDC / Keycloak BFF — RL799_V2
Règles d'or
- 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. - 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.
- 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 viavi.stubEnvdeviennent ordre-dépendants ; seule la CONFIG est lazy-memoizée). - Invariant sacré de l'identité fédérée : le validateur de token ne sort QUE l'identité (
sub+emailinformatif), JAMAIS un claim d'autorisation (realm_access.roles, grade, office).role/officesdérivés EXCLUSIVEMENT de la DB locale. Test d'invariant obligatoire : forger un token avecrealm_access.roles: ['admin']et vérifier qu'il n'accorde RIEN (403 sur route admin). - 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é partsc, 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
joseest 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
- équivalent iron-session sans dépendance supplémentaire si
- 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
- 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é. - JWE
alg: 'dir'+enc: 'A256GCM'(AEAD) avecsetIssuedAt/setExpirationTime→ l'expdu JWE donne l'expiration de session. - Whitelist explicite des algos au déchiffrement (
keyManagementAlgorithms: ['dir'],contentEncryptionAlgorithms: ['A256GCM']) — cf. pattern jose — whitelist d'algorithme. - Secret DÉDIÉ par usage (jamais le secret de chiffrement-au-repos), validé
/^[0-9a-f]{64}$/i— PAS seulementlength === 64(64 chars non-hex → Buffer < 32 bytes → erreur cryptique ;A256GCM direxige exactement 32 bytes). Fail-fast avec hint de génération, lu lazy NON memoizé (testabilité). - 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. - La frontière est une LIB : elle ne fabrique jamais de
Response— la couche jonction traduitnullen 401. - 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
subOIDC) à unUserlocal, 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 → Userlocal 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
- Migration purement additive :
ADD COLUMN <sub> 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. - Le finder vit au repository (
route → service → repository), retourne EXACTEMENT le shape figé par la couche jonction (ex.{ id, email, role, isActive }), viaselectminimal — jamais le mapper complet du record. - Fail-safe : try/catch →
null(une panne DB ne doit pas 500 le chemin authentifié). - Ne filtre PAS sur l'état actif : retourne
isActivetel quel — la décision d'accepter/rejeter appartient à la jonction (source unique). - 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). - No-leak : l'
idpSubest une clé de liaison interne — jamais en réponse API, jamais loggé. LeuserIdexposé en aval est l'idLOCAL, jamais lesub. - 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
subsur 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).
- 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
- 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. - 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. - Idempotent par chercher-avant-créer : interroger l'externe par clé naturelle (email
exact=true) avant de créer ; réutiliser l'existant. - 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). - 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. - 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.
- Flag-gated
=== 'true'lu à chaque requête : flag off = zéro appel réseau, zéro branche, zéro delta. - Secrets jamais loggés (token, client_secret) ; ne logger que des identifiants non-sensibles (email, userId, statut).
- Cas vécu : RL799 K1.4
provisionMemberIdentitygreffé surhandleCreateMember, 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
- la jonction reste une LIB (pas de fabrication de
- 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
- La jonction remonte un champ
sessionCookieToApply?: stringdans son contexte de retour — leSet-Cookiedu nouveau cookie chiffré, calculé après refresh réussi. La LIB ne fabrique pas de cookie, elle REMONTE un signal. - 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. - Marge de refresh anticipé (~30 s) : rafraîchir un token qui expire dans < marge, pour couvrir le temps de traitement + dérive d'horloge.
- Fail-closed : le helper de refresh retourne
nullsur tout échec (refresh token mort/révoqué, panne IdP), jamais de throw. La jonction mappenull→ 401 code distinct (SESSION_EXPIRED) + purge du cookie zombie (clear immédiat, sinon le cookie mort reboucle). - Convention d'unité epoch en SECONDES (OIDC) au recalcul de
expiresAtpost-refresh — un mélange s/ms est un bug classique coûteux. - Invariant minimisation : après refresh, repasser par le validateur de token qui n'extrait QUE l'identité (sub/email).
role/permissionsviennent TOUJOURS de la DB. - 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). - 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+sessionCookieToApplyconsommé parhandleGetSession, openid-client v6refreshTokenGrant.
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 NULLaprè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 deployne 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
- 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, PASprisma/migrations/). Le schéma déclaré reste réversible (String?). - 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. - 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 ».
- 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,
nullau 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)
- 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. - 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). - 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.
- 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). - 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. - GET strictement read-only (anti-prefetch SafeLinks Outlook) ; mutation seulement au POST (clic explicite). À TESTER : un GET n'écrit rien en DB.
- Rate-limiter IP dédié, AJOUTÉ au registre central de reset (sinon flakiness inter-fichiers).
- Domaine d'erreur dédié (pas de réutilisation d'un code d'un autre domaine public).
- 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 :
- 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 ;
- 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).