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

680 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.
---
<a id="pattern-format-derreur-api-standardise"></a>
## Pattern : Format d'erreur API standardisé
- Objectif : fournir des erreurs prévisibles, exploitables et cohérentes pour tous les clients.
- Contexte : API consommée par front-end, automatisations ou intégrations externes.
- Quand l'utiliser : dès qu'une API est exposée à autre chose qu'un usage interne trivial.
- Quand l'éviter : jamais.
- Avantage :
- Debug plus rapide
- UX maîtrisée côté client
- Observabilité améliorée
- Limites / vigilance :
- Discipline requise pour éviter les formats ad hoc
- Validé le : 25-01-2026
- Contexte technique : API HTTP agnostique
### Implémentation (exemple minimal)
```json
{
"error": {
"code": "USER_NOT_FOUND",
"message": "Utilisateur introuvable",
"requestId": "abc-123"
}
}
```
### Checklist
- Codes HTTP cohérents (4xx / 5xx)
- Codes d'erreur applicatifs stables
- Message utilisateur non technique
- requestId présent
---
<a id="pattern-middleware-correlation-requestid-traceid"></a>
## Pattern : Middleware de corrélation (requestId / traceId)
- Objectif : relier chaque requête aux logs et erreurs associées.
- Contexte : toute API ou service exposé.
- Quand l'utiliser : systématiquement en production.
- Quand l'éviter : jamais.
- Avantage :
- MTTR réduit drastiquement
- Debug cross-services possible
- Limites / vigilance :
- Doit être propagé partout (logs, erreurs, appels sortants)
- Validé le : 25-01-2026
- Contexte technique : Backend agnostique (HTTP)
### Implémentation (exemple minimal)
```txt
- Générer un requestId à l'entrée si absent
- Le propager dans le contexte de requête
- L'inclure dans chaque log et réponse d'erreur
```
### Checklist
- requestId généré ou repris d'un header existant
- Présent dans tous les logs
- Présent dans les erreurs retournées
---
<a id="pattern-anti-enumeration-auth-email"></a>
## Pattern : Anti-énumération sur endpoints auth liés à un email
- Objectif : empêcher qu'un endpoint auth révèle si un compte existe, n'existe pas ou n'est pas éligible.
- Contexte : reset de mot de passe, invitation, vérification de compte, login ou tout flux qui part d'un email utilisateur.
- Quand l'utiliser : dès qu'une requête auth touche un identifiant de type email.
- Quand l'éviter : jamais sur une surface exposée.
- Avantage :
- réduit la fuite d'information sur les comptes existants
- homogénéise les réponses côté client
- se combine bien avec les garde-fous anti-abus
- Limites / vigilance :
- ne protège pas seul contre le brute-force, à combiner avec du rate-limiting
- les logs internes doivent conserver la vraie cause sans l'exposer au client
- Validé le : 16-03-2026
- Contexte technique : Node.js / auth applicative / API HTTP
### Implémentation (exemple minimal)
```txt
- retourner la même réponse HTTP 200 qu'un compte existe ou non
- ne jamais distinguer "email inconnu", "email connu" ou "compte OAuth-only" dans la réponse
- journaliser la cause réelle côté serveur
- ajouter un rate-limiting basé sur email + IP
```
### Checklist
- Réponse client uniforme pour les cas compte connu/inconnu/non éligible
- Aucune fuite d'existence dans le message ou le code d'erreur
- Rate-limiting présent sur les endpoints exposés
- Logs internes exploitables
---
<a id="pattern-token-usage-unique"></a>
## Pattern : Token à usage unique — génération, hash et invalidation atomique
- Objectif : standardiser la création et la consommation de tokens sensibles sans stocker de secret brut en base.
- Contexte : invitation, reset de mot de passe, vérification d'email, lien magique ou tout token one-shot.
- Quand l'utiliser : pour tout token à usage unique transmis à l'utilisateur.
- Quand l'éviter : sessions longues ou secrets devant être relus en clair côté serveur.
- Avantage :
- réduit l'impact d'une fuite de base
- garde des tokens URL-safe
- favorise une consommation atomique et réutilisable
- Limites / vigilance :
- la consommation doit rester atomique
- la politique d'expiration doit être explicite
- Validé le : 16-03-2026
- Contexte technique : Node.js `crypto` / Prisma / email ou URL signée
### Implémentation (exemple minimal)
```txt
- générer le token avec `crypto.randomBytes(32).toString("base64url")`
- stocker uniquement le hash SHA-256 du token en base
- transmettre le token brut uniquement via URL ou email
- recalculer le hash côté serveur lors de la consommation
- invalider le token dans une transaction atomique après usage
```
### Checklist
- Token brut jamais persisté en base
- Hash recalculé côté serveur pour la vérification
- Expiration explicite
- Invalidation atomique après consommation
---
<a id="pattern-autorisation-interne-minimale"></a>
## Pattern : Autorisation interne minimale sans RBAC complet
- Objectif : sécuriser une capacité interne sensible sans ouvrir trop tôt un chantier RBAC complet.
- Contexte : application avec peu de rôles, besoin ponctuel d'une capacité admin ou opérateur clairement identifiée.
- Quand l'utiliser : quand une story métier demande un pouvoir interne limité mais réel.
- Quand l'éviter : si les permissions deviennent nombreuses, hiérarchiques ou contextuelles.
- Avantage :
- sécurisation rapide et lisible d'une capacité sensible
- source de vérité backend explicite
- chemin d'évolution propre vers un RBAC plus complet
- Limites / vigilance :
- ne pas laisser proliférer des rôles ad hoc non gouvernés
- ne remplace pas un vrai modèle de permissions si le domaine grossit
- Validé le : 10-03-2026
- Contexte technique : NestJS / auth par session ou JWT / API métier interne
### Implémentation (exemple minimal)
```txt
- introduire un enum de rôle minimal côté backend (ex. USER | ADMIN)
- propager ce rôle dans la session ou le token d'auth
- créer un décorateur + guard dédiés pour la capacité sensible
- interdire les booléens front, emails hardcodés ou `if` dispersés dans les contrôleurs
```
### Checklist
- Le rôle vit dans la source de vérité backend
- Le rôle est propagé dans le mécanisme d'auth existant
- Les endpoints sensibles passent par un guard dédié
- Aucun contrôle d'accès critique n'est piloté par le front
- Le passage à RBAC reste possible sans casser le contrat existant
---
<a id="pattern-auth-operations-atomiques"></a>
## Pattern : Opérations auth sensibles — atomiques, idempotentes et cohérentes
- Objectif : garantir que les opérations multi-étapes auth (reset, logout, révocation) ne laissent jamais un état incohérent.
- Contexte : tout flux auth qui combine plusieurs writes : hash de mot de passe, invalidation de token, suppression de session.
- Quand l'utiliser : systématiquement sur toute opération qui touche plusieurs tables auth en séquence.
- Quand l'éviter : opérations de lecture pure.
- Avantage :
- pas de token valide après reset de mot de passe si l'opération est interrompue
- suppression de session idempotente (P2025 absorbé silencieusement)
- comportement prévisible même en cas de retry ou de concurrence
- Limites / vigilance :
- `$transaction` Prisma ne couvre pas les effets de bord réseau (email, cookies) — ces étapes restent hors transaction
- Validé le : 16-03-2026
- Contexte technique : Node.js / Prisma / auth par session ou token
### Implémentation (exemple minimal)
```typescript
// consumePasswordReset — atomique dans une transaction
await prisma.$transaction([
prisma.passwordResetToken.update({
where: { tokenHash },
data: { consumedAt: new Date() },
}),
prisma.user.update({
where: { id: userId },
data: { passwordHash: newHash },
}),
prisma.session.deleteMany({ where: { userId } }),
]);
// Suppression de session — idempotente (P2025 absorbé)
try {
await prisma.session.delete({ where: { sessionToken } });
} catch (err) {
if (err?.code !== 'P2025') throw err; // session déjà supprimée → OK
}
```
### Checklist
- [ ] Toute opération hash + update + delete dans une `$transaction`
- [ ] `P2025` absorbé silencieusement sur les suppressions de session
- [ ] Effets de bord hors transaction documentés (cookie, email)
- [ ] Tests couvrant le cas d'une session déjà expirée
---
<a id="pattern-scope-minimal-cookie-refresh"></a>
## Pattern : Scope minimal du cookie refresh token
- Objectif : limiter l'exposition du cookie refresh token au strict minimum.
- Contexte : migration d'un stockage localStorage vers cookies httpOnly pour les tokens d'authentification.
- Quand l'utiliser : dès qu'un refresh token est transmis via cookie.
- Quand l'éviter : jamais.
- Avantage :
- réduit la surface d'attaque — le cookie ne voyage qu'avec les requêtes de refresh
- évite l'envoi inutile du refresh token sur les endpoints auth (login, password, invitations)
- Limites / vigilance :
- vérifier que le path est compatible avec le routing réel de l'endpoint de refresh
- Validé le : 08-04-2026
- Contexte technique : auth / cookies httpOnly — RL799_V2
### Règle
- Le cookie `refresh_token` doit avoir `Path=/api/auth/refresh` (pas `/api/auth`). Seul l'endpoint de refresh a besoin de recevoir ce cookie.
- Plus le path est large, plus la surface d'attaque est grande (le cookie voyage avec chaque requête matchant le path).
### Checklist
- [ ] `Path=/api/auth/refresh` sur le cookie refresh token
- [ ] `Path=/` uniquement pour l'access token (nécessaire sur toutes les routes API)
- [ ] Vérifier que l'endpoint de refresh reçoit bien le cookie après changement de path
---
<a id="pattern-distinction-401-403"></a>
## 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
---
<a id="pattern-auth-dernier-admin-actif"></a>
## 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
---
<a id="pattern-auth-audit-best-effort-vs-regalien"></a>
## 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
---
<a id="pattern-consume-single-use-pas-session-implicite"></a>
## 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
---
<a id="pattern-magic-link-consume-sans-fusion-table"></a>
## Pattern : Magic-link consume — mécanique partagée sans fusion table
- Objectif : factoriser la mécanique sécu d'un consume single-use (hash SHA-256, transaction atomique, révocation refresh tokens) sans fusionner les modèles DB des deux domaines.
- Contexte : projet avec deux flows partageant la même mécanique (invitation magic-link + reset password) mais des objets métier différents (invitation = audit riche, reset = purement technique).
- Quand l'utiliser : factorisation au niveau **service**, pas au niveau table.
- Quand l'éviter : un seul flow magic-link dans le projet — pas de factorisation prématurée.
- Avantage :
- audit historique riche pour les invitations (statut traçable)
- transaction atomique partagée (rotation/consume)
- rate limiter dédié par endpoint sans pollution cross-domaine
- Limites / vigilance :
- duplication contrôlée des modèles DB (acceptable — chaque domaine a son cycle de vie)
- Validé le : 28-04-2026
- Contexte technique : auth / magic-link / Prisma — RL799_V2
### Règles d'or
1. **Tokens stockés en hash SHA-256 uniquement** (`tokenHash @unique`). Le raw token n'est transmis que dans l'URL email, jamais persisté. Helper `hashXxxToken(raw: string): string` réutilisable côté service ET tests.
2. **PK technique `id String @id @default(uuid())`**, pas le token comme PK. Évite le couplage PK/sécurité, permet la rotation d'un token sans créer une nouvelle row.
3. **Transaction atomique consume** :
- Lock implicite via `findUnique({ where: { tokenHash }, include: { user } })`
- Check `status === 'active'`, `consumedAt === null`, `revokedAt === null`, `expiresAt > now`, `user.isActive === true`
- UPDATE token `status='consumed', consumedAt=now()`
- UPDATE user (password si applicable)
- UPDATE refresh_tokens `revokedAt=now()` (force re-login propre)
- UPDATE autres tokens actifs `status='revoked'` (anti-réutilisation)
- INSERT audit dans la transaction (atomicité stricte)
4. **Retour discriminé** :
```typescript
export type ConsumeResult =
| { ok: true; userId: string; email: string }
| { ok: false; reason: 'NOT_FOUND' | 'ALREADY_USED' | 'EXPIRED' | 'REVOKED' | 'USER_INACTIVE' };
```
Le frontend ne connaît qu'un code générique côté UX (`INVALID_X_TOKEN`) pour ne pas exposer d'oracle.
5. **Rate limiter dédié par endpoint** :
- `validate` : 30 req / 15 min / IP (oracle d'énumération)
- `consume` : 10 req / 15 min / IP (modifie le mot de passe)
- `resend` (admin) : 20 req / heure / admin
6. **Helpers transverses** : `revokeAndIssueXxx(input)` en transaction unique (couplé à un index unique partiel `WHERE status='active'`), `revokeXxxForUser(userId)` (appelé par `setUserActive(false)`), `getLatestXxxByUserIds(userIds[])` batch (anti N+1).
---
<a id="pattern-sentinelle-non-hashable-user-invite"></a>
## Pattern : Sentinelle non-hashable pour user en attente de mot de passe
- Objectif : éviter à la fois un `password: String?` nullable qui casse les chemins login/test/audit qui font tous des `select: { password }`, et un appel scrypt inutile (~100 ms par user invité).
- Contexte : user créé via invitation qui n'a pas encore défini son mot de passe.
- Quand l'utiliser : tout flow d'invitation où le user existe en base avant le set du password.
- Quand l'éviter : projet où le user n'est créé qu'au consume du magic link (pas de placeholder nécessaire).
- Avantage :
- le champ `password` reste `NOT NULL` en DB → aucun chemin code ne casse
- `verifyPassword` détecte le préfixe en early-return → 0 ms scrypt
- garantie cryptographique (pas conventionnelle) : le préfixe `!` est absent d'une sortie hex
- Limites / vigilance :
- la sentinelle est lisible en clair par un admin DB — acceptable car c'est un placeholder identifié, pas un secret
- Validé le : 28-04-2026
- Contexte technique : auth / scrypt — RL799_V2
### Implémentation
```typescript
// services/.../inviteService.ts
const buildInvitedPlaceholderPassword = (): string =>
`!INVITED_PENDING_${crypto.randomUUID()}`;
await prisma.user.create({
data: { email, password: buildInvitedPlaceholderPassword(), ... },
});
// lib/passwords.ts
export const verifyPassword = (password: string, stored: StoredPassword): boolean => {
if (typeof stored !== 'string' || typeof password !== 'string') return false;
if (stored.startsWith('!')) return false; // placeholder réservé, jamais matchable
// …logique scrypt habituelle
};
```
### Checklist
- [ ] Préfixe `!` (caractère **garanti** absent de l'alphabet hex `0-9a-f`)
- [ ] UUID embarqué pour l'unicité par user (utile pour debug audit, pas un secret)
- [ ] Test : `verifyPassword('anything', '!INVITED_PENDING_xxx') === false`
- [ ] AC dédié : `verifyPassword` rejette en 0 ms observable (pas d'appel scrypt)
---
<a id="pattern-ttl-court-bouton-resend"></a>
## 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)
---
<a id="pattern-hook-setuseractive-revoke-side-tokens"></a>
## Pattern : Hook `setUserActive(false)` → revoke side-tokens
- Objectif : éviter qu'un user désactivé puisse continuer à consommer un magic link reçu juste avant la désactivation et reprendre la main.
- Contexte : opération admin de désactivation user dans un projet avec tokens secondaires actifs (refresh tokens, invitations, futurs reset password tokens).
- Quand l'utiliser : tout endpoint qui transitionne `user.isActive` de `true` à `false`.
- Quand l'éviter : si la désactivation est purement métier (suspension UI) sans portée auth.
- Avantage :
- défense en profondeur (le checker du consume vérifie aussi `user.isActive` — ceinture + bretelles)
- les tokens orphelins ne survivent pas à la désactivation
- Limites / vigilance :
- best-effort tracé : les revokes ne doivent pas bloquer la désactivation (l'admin attend un retour immédiat)
- Validé le : 28-04-2026
- Contexte technique : auth / lifecycle user — RL799_V2
### Implémentation
```typescript
if (!body.isActive) {
try {
await revokeAllRefreshTokensForUser(userId);
} catch (err) {
console.error('[admin.users] refresh token revoke failed', err);
refreshTokenWarning = '...';
}
try {
await revokeInvitationsForUser(userId);
} catch (err) {
console.error('[admin.users] invitation revoke failed', err);
}
// À ajouter quand pertinent : reset password tokens, OAuth states, etc.
}
```
### Règles d'or
- **Best-effort tracé**, pas silencieux : `try { ... } catch { console.error(...) }`
- Warning UX en cas d'échec partiel : la réponse JSON peut contenir un champ optionnel `warning` que le frontend affiche
- **Double protection consume** : le checker du consume vérifie également `user.isActive === true` — même si le revoke échoue, l'user désactivé ne peut pas consommer
- AC dédié : "user actif avec invitation pending → admin désactive → consume échoue avec INVALID_TOKEN, pas de session créée"
---
<a id="pattern-magic-link-url-clean"></a>
## Pattern : Magic link "URL clean" — token signé HMAC + `history.replaceState`
- Objectif : ouvrir une page web qui pré-charge un contexte serveur (soireeId, eventId, inviteId) sans que l'identifiant ne reste visible dans l'URL navigable.
- Contexte : mail (convocation, invitation, RSVP) avec un bouton qui mène vers une landing page PWA. On veut éviter forward, capture, indexation involontaire.
- Quand l'utiliser : magic links publics vers une page qui pré-charge un contexte sensible.
- Quand l'éviter : magic links d'authentification membre — utiliser les patterns auth classiques (refresh + access httpOnly).
- Avantage :
- URL visible côté utilisateur ne contient pas l'identifiant
- HMAC garantit l'intégrité (custom claim `purpose` pour rejeter un token signé pour un autre usage)
- `sessionStorage` plutôt que `localStorage` → contexte meurt avec l'onglet
- Limites / vigilance :
- `algorithms: ['HS256']` imposé côté `jwt.verify` pour bloquer les "alg: none" attacks
- sécurité dépendante de l'isolation cryptographique du secret (cf. pattern dérivation HMAC)
- Validé le : 30-04-2026
- Contexte technique : Node.js crypto / Vue / Vite PWA — RL799_V2
### Architecture
```
Mail HTML
↓ Bouton (GET https://app/visit?t=<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
```typescript
import jwt from 'jsonwebtoken';
export function signAccessToken(contextId: string, expiresAt: Date): string {
const expSeconds = Math.floor(expiresAt.getTime() / 1000);
return jwt.sign(
{ sub: contextId, purpose: 'rsvp-v1', exp: expSeconds },
getDerivedSecret(),
{ algorithm: 'HS256' },
);
}
export function redeemAccessToken(token: string):
| { ok: true; contextId: string }
| { ok: false; reason: 'expired' | 'invalid' } {
try {
const decoded = jwt.verify(token, getDerivedSecret(), {
algorithms: ['HS256'],
}) as { sub?: string; purpose?: string };
if (decoded.purpose !== 'rsvp-v1') return { ok: false, reason: 'invalid' };
if (typeof decoded.sub !== 'string') return { ok: false, reason: 'invalid' };
return { ok: true, contextId: decoded.sub };
} catch (err) {
if (err instanceof jwt.TokenExpiredError) return { ok: false, reason: 'expired' };
return { ok: false, reason: 'invalid' };
}
}
```
### Frontend — landing minimaliste
```typescript
onMounted(async () => {
const token = route.query.t as string;
if (!token) return showError('Lien incomplet');
const result = await redeemToken(token);
saveSessionContext(result); // sessionStorage
if (typeof window !== 'undefined' && window.history?.replaceState) {
window.history.replaceState(null, '', '/inscription');
}
await router.replace({ name: 'inscription' });
});
```
### Choix d'expiration
- Magic link auth : court (15 min 24 h), token consommable une fois
- RSVP événement : long (jusqu'à la date)
- Invitation one-shot : moyen (7-30 jours), invalidable à l'usage
---
<a id="pattern-isolation-cryptographique-hmac"></a>
## Pattern : Isolation cryptographique — secret dérivé via HMAC
- Objectif : signer plusieurs types de tokens isolés cryptographiquement sans ajouter une nouvelle env var par usage.
- Contexte : projet avec un `JWT_SECRET` racine (auth membre) qui doit signer un autre type de token (magic link, RSVP, webhook) sans cross-domain attack possible.
- Quand l'utiliser : besoin d'un secret de signature isolé sans nouvelle clé à provisionner et à tourner.
- Quand l'éviter : si le projet supporte déjà un système de gestion de clés multiples (KMS, Vault) — utiliser le mécanisme natif.
- Avantage :
- une seule env var racine à provisionner et tourner
- HMAC est one-way : un attaquant qui obtient le secret dérivé ne peut pas remonter au racine
- reproductible : `(JWT_SECRET, purpose)` → même secret, pas d'état à persister
- versionnable via le purpose (`-v1`, `-v2`) : rotation possible en bumpant le purpose
- Limites / vigilance :
- n'est PAS un substitut à la rotation de clés : si `JWT_SECRET` est compromis, tous les secrets dérivés le sont aussi
- HKDF est plus rigoureux pour la dérivation formelle ; pour un simple isolement d'usage, `HMAC(secret, purpose)` suffit
- Validé le : 30-04-2026
- Contexte technique : Node.js crypto — RL799_V2
### Implémentation
```typescript
import { createHmac } from 'node:crypto';
const TOKEN_PURPOSE = 'magic-link-v1';
let cachedSecret: Buffer | null = null;
function getDerivedSecret(): Buffer {
if (cachedSecret) return cachedSecret;
const root = process.env.JWT_SECRET;
if (!root) throw new Error('JWT_SECRET requis');
cachedSecret = createHmac('sha256', root).update(TOKEN_PURPOSE).digest();
return cachedSecret;
}
```
### Test d'isolation
```typescript
test('rejette un token signé avec un autre purpose', async () => {
const tokenAsMember = jwt.sign(
{ sub: 'x' },
process.env.JWT_SECRET!,
{ algorithm: 'HS256', expiresIn: '15m' },
);
const result = redeemToken(tokenAsMember);
expect(result.ok).toBe(false);
});
```
### Applications
- Tokens magic link / RSVP / invitation
- Signature de webhooks sortants
- Tokens d'unsubscribe email (lien direct sans login)
- Tokens d'accès aux ressources publiques limitées dans le temps
---