Files
_Assistant_Lead_Tech/knowledge/backend/patterns/auth.md
T
MaksTinyWorkshop f1b783407a docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)
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>
2026-06-25 11:25:02 +02:00

52 KiB
Raw Blame History


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)

{
  "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 :
    • $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)

// 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

  • 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

  • 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é :

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.

  1. 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
  2. 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

// 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

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"

  • 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=<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_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

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 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: [<algo émis par l'IdP>] (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.
  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 <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.
  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).
  • 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).

  • 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é.

  • 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).