Files
_Assistant_Lead_Tech/knowledge/backend/patterns/auth.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

28 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

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