Autorisation et session dans Next.js
Document sur le frontend qui reçoit une session du backend (tokens ou
identifiant de session) et doit la stocker, la prolonger et la vérifier. Si le
projet utilise une bibliothèque ou un fournisseur (Auth.js, Better Auth, Clerk,
etc.), la mécanique de session est gérée par celui-ci, mais les règles des
sections 2, 6 à 10 restent applicables.
Code — references/implementation.md.
Priorité des règles. Les instructions de l'utilisateur et les fichiers agent du projet sont plus importants. Si le backend impose autre chose (par exemple, s'il place lui-même le cookie), suis le backend et note la différence dans le README ou un ADR.
Notations : MUST — ne pas enfreindre ; SHOULD — par défaut oui, une dérogation nécessite une justification.
1. Choix du modèle
| Modèle | Quand |
|---|---|
| BFF : les route handlers Next reçoivent les tokens du backend et les placent dans un cookie HttpOnly | Le backend renvoie les tokens dans le corps de la réponse. Option par défaut pour Next |
| Le backend place lui-même le cookie HttpOnly | Frontend et API sur le même site, backend sous ton contrôle |
| Bibliothèque ou fournisseur | OAuth, MFA, gestion des utilisateurs nécessaires — ne les implémente pas toi-même |
| Access en mémoire, refresh dans un cookie HttpOnly | SPA sans BFF |
Tokens dans localStorage/sessionStorage | Interdit : toute XSS emporte la session |
Deuxième embranchement — ce que le BFF proxy :
| Option | Avantages | Inconvénients |
|---|---|---|
| Seulement login / refresh / logout, les autres requêtes vont directement à l'API avec le cookie | Le BFF n'est pas un goulot d'étranglement ; moins de code | L'API lit le token depuis le cookie et configure CORS ; les cookies doivent atteindre le domaine de l'API |
| Toutes les requêtes via le BFF | L'API ne connaît ni cookie ni CORS ; une seule origine | Chaque requête ajoute un saut ; le BFF doit être dimensionné |
Le choix est consigné dans un ADR.
2. Cookie (MUST)
HttpOnlypour tout ce qui donne accès (access, refresh, id de session).Secureen production. Localement, les navigateurs acceptentSecuresurhttp://localhost, mais pas sur d'autres hôtes http.SameSite=Laxpar défaut.Strictest plus robuste contre CSRF, mais l'utilisateur arrivant par un lien depuis un e-mail ou un autre site semble non authentifié lors de la première navigation — choisis en connaissance de cause et note la raison.Path=/; durée de vie basée surexpdu token ou la réponse du backend, pas « à vue de nez ».- SHOULD : préfixe
__Host-dans le nom (nécessiteSecure,Path=/, sansDomain) — le cookie ne peut pas être usurpé depuis un sous-domaine. - Les cookies sont placés et supprimés uniquement côté serveur (route
handler, Server Action,
proxy.ts). Les Server Components ne peuvent pas placer de cookies. - Le cookie non lisible depuis JS est le seul vecteur de session. Un drapeau
du type
is_authenticatedsansHttpOnlyest uniquement autorisé comme indicateur pour l'UI et n'est jamais utilisé pour des décisions d'accès.
3. BFF : route handlers (MUST)
- La connexion est validée par un schéma (
zod) ; un corps mal formé renvoie400. - Les handlers mutateurs vérifient
Origincontre l'origine de l'application :SameSite=Laxne protège pas contre les requêtes provenant de sous-domaines du même site. Les Server Actions comparentOriginavecHosteux-mêmes. - Le contexte client est transmis au backend :
X-Forwarded-For,User-Agent— sinon le rate limit du backend ne voit que l'IP du serveur. - Les erreurs du backend sont traduites en réponse avec
kindet un statut (401,403,422,429avecRetry-After,503), le texte du backend n'est pas divulgué. - Refresh : si le backend rejette le token de refresh — supprimer le
cookie et répondre
401. Si le backend est indisponible (réseau, 5xx) — ne pas toucher au cookie, répondre503: une panne temporaire ne doit pas déconnecter. - Logout : révoquer le token côté backend (l'erreur est journalisée mais pas affichée), supprimer le cookie dans tous les cas.
4. Où vérifier la session (MUST)
| Endroit | Ce qu'il fait | Ce qu'il ne fait pas |
|---|---|---|
proxy.ts (Next 16+, auparavant middleware.ts) | Vérification optimiste basée sur la présence du cookie : redirection vers la connexion et depuis les pages de connexion | N'interroge pas la BDD ni l'API ; n'est pas la seule protection |
Data Access Layer (verifySession() dans un module server-only) | Vérification près des données : dans les Server Components, route handlers, Server Actions | — |
| Layout | Rien : les layouts ne sont pas réaffichés lors de la navigation | Ne vérifie pas l'accès |
| Backend | Source de vérité : vérifie le token à chaque requête | — |
- Chaque route handler et Server Action est un point d'entrée public et
appelle
verifySession()lui-même. proxy.tsvérifie le cookie de refresh (ou l'id de session) : l'access a pu expirer alors que la session est toujours active.- Correspondance du chemin par segments :
/appet/app/…, mais pas/apple. - Avec
cacheComponents(par défaut dans les projets Next 16.4), la lecture de la session et la mise en cache des données utilisateur ont leurs propres règles : avant d'écrire le DAL, lisnode_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md. - Les droits (rôle, propriété) sont décidés en fonction des données vérifiées :
via la signature (JWKS,
jose) ou via la réponse du backend. Un payload JWT non vérifié n'est pas une base pour l'accès. - Les Server Components ne peuvent pas mettre à jour le cookie. Si l'access a
expiré au moment du rendu serveur : soit
proxy.tsrenouvelle la session (il peut placer un cookie dans la réponse — fais-le uniquement pour les navigations document, pas pour le prefetch, et prends en compte la rotation des tokens de refresh), soit la page affiche un chargement côté client et le refresh est effectué côté client.
5. Client : refresh sans concurrence (MUST)
- Détection de « non autorisé » : HTTP
401, ou dans une réponse GraphQL (statut200) au moins une erreur avecextensions.code === "UNAUTHENTICATED"(errors.some, paserrors[0]). La comparaison avec le texte du message uniquement si le backend ne fournit pas de codes, dans une seule fonction, avec commentaire et test. - Single-flight : les requêtes parallèles recevant 401 attendent un seul
refresh commun et utilisent son résultat. La promesse est réinitialisée
dans
finally. - La requête d'origine est rejouée une fois. Un deuxième 401 signifie une session perdue, sans deuxième refresh.
- La requête de refresh elle-même ne passe pas par l'intercepteur (sinon récursion).
- Le résultat du refresh distingue trois cas : accordé (
true) → rejouer ; rejeté (false) → session expirée ; réseau ou 5xx (exception) → erreurnetwork, sans déconnexion. - Pendant la déconnexion, le refresh est bloqué : sinon une requête tombant en 401 au moment du logout rétablirait immédiatement la session.
- Session expirée : vider le cache des requêtes, rediriger vers la page de
connexion avec
callbackUrlde la page courante. - Le corps de la requête à rejouer doit être rejouable (chaîne,
FormData), pas unReadableStreamà usage unique.
6. Redirection après connexion (MUST)
callbackUrl(next,returnTo) est vérifié par la fonctiongetSafeRedirect(raw, fallback, appOrigin): elle rejette\et les caractères de contrôle (les navigateurs transforment/\evil.comet/<tab>/evil.comen//evil.com), analysenew URL(raw, appOrigin), compare l'origine et ne renvoie que le chemin. La vérification « commence par/et non par//» est insuffisante.- La série de tests associée se trouve dans
references/implementation.md, section 7. - Après la connexion, les Server Components doivent voir les nouveaux
cookies : navigation complète (
window.location.assign) ourouter.replace+router.refresh().
7. Déconnexion (MUST)
- Le serveur supprime le cookie (section 3).
- Le client vide le cache des requêtes (
queryClient.clear()) et tous les stores clients contenant les données de l'utilisateur : l'utilisateur suivant dans le même onglet ne doit pas voir les données d'autrui, ne serait-ce qu'un instant. - Navigation vers la page de connexion sans possibilité de revenir « en
arrière » vers un écran protégé mis en cache (
replaceou navigation complète). - Confirmation de déconnexion — selon le souhait du produit ; le bouton doit être désactivé pendant la requête — c'est obligatoire.
- SHOULD : les autres onglets sont informés de la déconnexion
(
BroadcastChannelou événementstoragebasé sur un indicateur), sinon ils continuent d'afficher les données jusqu'au premier 401.
8. Tokens dans les liens (réinitialisation de mot de passe, confirmation d'email)
- Le token est lu depuis l'URL une fois dans un état, puis l'adresse est
remplacée sans le token (
router.replace(pathname)) : il ne reste pas dans l'historique et ne part pas dans leReferer. - Pour ces pages —
Referrer-Policy: no-referreret aucun script tiers (analytique, widgets). - Le token n'est pas journalisé. L'unicité d'utilisation et la durée de vie sont gérées par le backend ; le frontend affiche un écran explicite « lien expiré » avec une action « demander un nouveau lien ».
9. Formulaires de connexion et de récupération
- La récupération de mot de passe répond de manière identique pour un email existant ou inexistant (« si le compte existe, un email a été envoyé »), sans révéler qui est inscrit. Si le backend divulgue cette information, la noter comme limitation.
- L'envoi est bloqué pendant la requête ; un nouvel envoi ne crée pas une deuxième requête.
429: message avec le temps d'attente issu deRetry-After, s'il est présent.- Champs :
type="email"+autoComplete="email"; mot de passe —autoComplete="current-password"pour la connexion et"new-password"pour l'inscription et le changement ; la copie n'est pas bloquée. - Bouton « afficher le mot de passe » —
type="button",aria-labeletaria-pressed, ne vole pas le focus du champ. - L'erreur « email ou mot de passe incorrect » ne précise pas ce qui est faux.
- Les erreurs du serveur sont associées aux champs par code ; une erreur générale est placée au-dessus du formulaire.
10. Données et journaux
- Les réponses dépendantes de l'utilisateur ne sont pas mises dans les caches
partagés (
"use cache", CDN). QueryClient côté serveur est nouveau pour chaque requête. - Les journaux ne contiennent pas de tokens, mots de passe, emails. Au
maximum — l'id de l'utilisateur et le
kindde l'erreur. - Le payload du token de session est minimal : id et rôle, sans données personnelles.
11. Tests
- Unitaire :
getSafeRedirect(toute la série de références) ; détection de « non autorisé » (401, codes GraphQL, corps vide, HTML au lieu de JSON). - Single-flight : N requêtes parallèles avec 401 → un seul refresh, N
rejeux ; refresh rejeté → aucun rejeu, un seul appel à
onSessionExpired; refresh échoue réseau → erreurnetwork, sans déconnexion ; deuxième 401 après un refresh réussi → pas de deuxième refresh ; pendant le logout → pas de refresh. - Route handlers : drapeaux du cookie ; refresh rejeté → cookie
supprimé ; backend indisponible → cookie conservé,
503; corps invalide →400;Originétranger →403. - e2e : connexion → page protégée → déconnexion → page protégée inaccessible ; expiration de l'access (TTL court dans l'environnement de test) → refresh transparent ; lien de réinitialisation de mot de passe → token disparu de la barre d'adresse.
12. Definition of Done
- Aucun token n'est lu depuis JS ;
localStoragen'est pas utilisé pour la session. - Cookie :
HttpOnly,Secureen prod,SameSite,Path, durée basée sur le token. - Refresh : rejet et indisponibilité du backend sont distingués.
- Single-flight avec résultat, un seul rejeu, blocage pendant le logout — couvert par les tests.
-
proxy.ts— uniquement vérification optimiste ; DAL appelé dans les handlers, actions et Server Components. -
getSafeRedirectavec l'ensemble complet des tests. - La déconnexion vide le cache et les stores clients.
- Les tokens des liens sont retirés de la barre d'adresse.
- Les journaux ne contiennent pas de tokens ni de données personnelles.
- Le choix du modèle et du proxy est consigné dans un ADR.