Lire la session à chaque requête

Le routeur d’authentification et ses six procédures, puis getSessionFromRequest : la lecture du jeton, sa vérification, la session relue en base avec le membre et son groupe Administrateurs en une requête, l’expiration, la double vérification jeton + base, et la projection vers SessionClient.

Précédemment. Une connexion réussie laisse une ligne en base et un cookie qui porte un jeton signé ne contenant que son identifiant.

Mouvement 2 — La vie d’une session#

Nous avons construit la naissance d’une session.

Mais une session qui naît et qui ne sait pas vivre est inutile.

Comment, à la prochaine requête du membre, le backend va-t-il reconstruire son identité ? Comment cette identité sera-t-elle exploitée par les procédures ? Comment la détruire à la déconnexion, la nettoyer quand elle expire, la révoquer quand le mot de passe change ? Et comment reprendre la main sur un compte dont on a oublié le mot de passe ?

C’est tout cela que nous allons construire dans ce second mouvement.

  • Ce chapitre — Le routeur d’authentification dans son ensemble, puis le cœur battant du système : getSessionFromRequest, qui reconstruit l’identité à chaque requête, du cookie jusqu’au SessionClient.
  • Chapitre 5 — Comment createTRPCContext injecte cette session dans le contexte de chaque procédure, et comment protectedProcedure l’exploite.
  • Chapitre 6 — Comment une session meurt : la déconnexion, la purge des sessions expirées, et le changement de mot de passe qui ferme les autres appareils.
  • Chapitre 7 — Le cas le plus sensible : le mot de passe oublié, avec son jeton dont seule l’empreinte est gardée, sa durée courte, son délai entre deux demandes, et sa consommation atomique.

À la fin de ce mouvement, nous saurons comment une session vit, comment elle est exploitée partout dans le backend, et comment elle meurt — volontairement, passivement, ou par sécurité.


Le routeur auth : l’orchestration des intentions#

Nous avons vu le service. Voyons maintenant comment il est exposé par tRPC.

Vue d’ensemble du routeur d’authentification
TS
packages/backend/src/modules/auth/router.ts
/** * Connexion, déconnexion, session courante, mot de passe (changement, oubli, réinitialisation). * `getSession` est appelée en permanence par le front : elle répond à « qui suis-je, maintenant ? ». */export const authRouter = router({  login: publicProcedure    // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message.    .input(loginSchema)    .mutation(async ({ input }) => {      const { email, password } = input;      try {        await loginService(email, password);        return {          success: true as const,        };      } catch (error) {        // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un        // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte,        // est journalisée par la route /api/trpc, et le formulaire affiche son message générique.        if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") {          return {            success: false as const,            error: "INVALID_CREDENTIALS" as const,          };        }        // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique.        if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") {          return {            success: false as const,            error: "TOO_MANY_ATTEMPTS" as const,          };        }        throw error;      }    }),  logout: protectedProcedure.mutation(async () => {    await deleteSessionFromCookie();    return {      success: true,    };  }),  /**   * Changement de mot de passe (utilisateur connecté).   * Vérifie le mot de passe actuel, refuse un nouveau identique, re-hash, et   * DÉCONNECTE LES AUTRES APPAREILS (supprime leurs sessions ; garde la   * courante via le sessionId du cookie).   */  changePassword: protectedProcedure    .input(changePasswordSchema)    .mutation(async ({ ctx, input }) => {      const userId = ctx.sessionClient.user.id;      const user = await ctx.prisma.user.findUnique({        where: { id: userId },        select: { password: true },      });      if (!user) {        throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable." });      }      // BAD_REQUEST, pas UNAUTHORIZED : le membre EST connecté ; c'est la saisie qui est fausse.      // UNAUTHORIZED signifie « aucune session », et un client pourrait le traiter comme une déconnexion.      // Essais limités : une session volée ne permet pas d'essayer des mots de passe sans fin.      await assertPasswordOfConnectedUser({        userId,        password: input.currentPassword,        hash: user.password,        wrongMessage: "Mot de passe actuel incorrect.",      });      const same = await verifyPassword(input.newPassword, user.password);      if (same) {        throw new TRPCError({          code: "BAD_REQUEST",          message: "Le nouveau mot de passe doit être différent de l'actuel.",        });      }      const hashed = await hashPassword(input.newPassword);      const payload = verifyJwt(await getToken());      const currentSessionId = payload?.sessionId ?? null;      await ctx.prisma.$transaction(async (tx) => {        await tx.user.update({          where: { id: userId },          data: { password: hashed },        });        await tx.session.deleteMany({          where: {            userId,            ...(currentSessionId ? { NOT: { id: currentSessionId } } : {}),          },        });      });      return { success: true };    }),  getSession: publicProcedure.query(async ({ ctx }) => {    return ctx.sessionClient;  }),

Le routeur auth expose six procédures :

  • login (publique)
  • logout (protégée)
  • changePassword (protégée)
  • getSession (publique)
  • requestPasswordReset (publique)
  • resetPassword (publique)

Pourquoi login est publique mais logout est protégée#

C’est une règle de bon sens, mais qui mérite d’être explicitée.

login doit être appelable par un visiteur non connecté — sinon personne ne pourrait jamais se connecter. Donc publicProcedure.

logout et changePassword concernent la session en cours : un anonyme qui les appelle n’a rien à déconnecter ni à changer. Donc protectedProcedure.

Ces choix sont explicites dans le code, pas implicites.


Pourquoi getSession est publique#

Cette procédure est publicProcedure parce que le front doit pouvoir l’appeler à tout moment, même quand personne n’est connecté.

Quand le front ne sait pas encore si quelqu’un est connecté, il appelle getSession pour le découvrir. Si la réponse est null, il affiche l’interface d’un visiteur. Si elle contient un membre, il remplit son état.

Si getSession était protégée, l’application ne pourrait jamais apprendre qu’un visiteur est anonyme : elle recevrait une erreur d’authentification, sans pouvoir distinguer « personne n’est connecté » de « quelque chose est cassé ».


Lecture guidée de la procédure login#

login : du formulaire au résultat exploitable

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

publicProcedure : un choix explicite
TS
packages/backend/src/modules/auth/router.ts
guided extract: lines 28-59focus line 1
import { router, publicProcedure, protectedProcedure } from "@backend/trpc/core";import { deleteSessionFromCookie } from "@backend/lib/session/session.server";import { loginService } from "@backend/modules/auth/services/auth.service";import {  createPasswordResetToken,  hashResetToken,  RESET_TOKEN_COOLDOWN_MS,  RESET_TOKEN_TTL_MS,} from "@backend/modules/auth/services/passwordReset.service";import { sendPasswordResetEmail } from "@backend/email/services/sendPasswordResetEmail";import { TRPCError } from "@trpc/server";import { hashPassword, verifyPassword } from "@backend/lib/auth/password";import { getToken, verifyJwt } from "@backend/lib/session/session.server";import {  changePasswordSchema,  loginSchema,  requestPasswordResetSchema,  resetPasswordSchema,} from "@contracts/auth/auth.schema";import { assertPasswordOfConnectedUser } from "@backend/lib/security/passwordCheck";/** * Connexion, déconnexion, session courante, mot de passe (changement, oubli, réinitialisation). * `getSession` est appelée en permanence par le front : elle répond à « qui suis-je, maintenant ? ». */export const authRouter = router({  login: publicProcedure    // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message.    .input(loginSchema)    .mutation(async ({ input }) => {      const { email, password } = input;      try {        await loginService(email, password);        return {          success: true as const,        };      } catch (error) {        // Un mauvais identifiant fait partie du fonctionnement normal du formulaire : c'est un        // résultat. Tout le reste (base injoignable, bug) est une vraie erreur : elle remonte,        // est journalisée par la route /api/trpc, et le formulaire affiche son message générique.        if (error instanceof TRPCError && error.message === "INVALID_CREDENTIALS") {          return {            success: false as const,            error: "INVALID_CREDENTIALS" as const,          };        }        // Trop d'échecs pour cette adresse : un résultat aussi, que le formulaire explique.        if (error instanceof TRPCError && error.message === "TOO_MANY_ATTEMPTS") {          return {            success: false as const,            error: "TOO_MANY_ATTEMPTS" as const,          };        }        throw error;      }    }),  logout: protectedProcedure.mutation(async () => {    await deleteSessionFromCookie();    return {      success: true,    };  }),  /**   * Changement de mot de passe (utilisateur connecté).   * Vérifie le mot de passe actuel, refuse un nouveau identique, re-hash, et   * DÉCONNECTE LES AUTRES APPAREILS (supprime leurs sessions ; garde la   * courante via le sessionId du cookie).   */  changePassword: protectedProcedure    .input(changePasswordSchema)    .mutation(async ({ ctx, input }) => {      const userId = ctx.sessionClient.user.id;      const user = await ctx.prisma.user.findUnique({        where: { id: userId },        select: { password: true },      });      if (!user) {        throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable." });      }      // BAD_REQUEST, pas UNAUTHORIZED : le membre EST connecté ; c'est la saisie qui est fausse.      // UNAUTHORIZED signifie « aucune session », et un client pourrait le traiter comme une déconnexion.      // Essais limités : une session volée ne permet pas d'essayer des mots de passe sans fin.      await assertPasswordOfConnectedUser({        userId,        password: input.currentPassword,        hash: user.password,        wrongMessage: "Mot de passe actuel incorrect.",      });      const same = await verifyPassword(input.newPassword, user.password);      if (same) {        throw new TRPCError({          code: "BAD_REQUEST",          message: "Le nouveau mot de passe doit être différent de l'actuel.",        });      }      const hashed = await hashPassword(input.newPassword);      const payload = verifyJwt(await getToken());      const currentSessionId = payload?.sessionId ?? null;      await ctx.prisma.$transaction(async (tx) => {        await tx.user.update({          where: { id: userId },          data: { password: hashed },        });        await tx.session.deleteMany({          where: {            userId,            ...(currentSessionId ? { NOT: { id: currentSessionId } } : {}),          },        });      });      return { success: true };    }),  getSession: publicProcedure.query(async ({ ctx }) => {    return ctx.sessionClient;  }),  requestPasswordReset: publicProcedure    .input(requestPasswordResetSchema)    .mutation(async ({ input, ctx }) => {      // Déjà nettoyée et mise en minuscules par emailSchema, comme toutes les adresses enregistrées.      const user = await ctx.prisma.user.findUnique({        where: { email: input.email },        select: { id: true, email: true },      });      if (!user) {        return { ok: true as const };      }      const lastToken = await ctx.prisma.passwordResetToken.findFirst({        where: { userId: user.id },        orderBy: { createdAt: "desc" },        select: { createdAt: true },      });      if (lastToken) {        const elapsed = Date.now() - lastToken.createdAt.getTime();        if (elapsed < RESET_TOKEN_COOLDOWN_MS) {          return { ok: true as const };        }      }      const { token, tokenHash } = createPasswordResetToken();      const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);      await ctx.prisma.passwordResetToken.create({        data: {          userId: user.id,          tokenHash,          expiresAt,          requestIp: ctx.requestIp ?? null,          userAgent: ctx.userAgent ?? null,        },      });      // L'envoi n'est PAS attendu : il prend plusieurs secondes, et une réponse lente pour une      // adresse connue, rapide pour une inconnue, trahirait qui a un compte. Jamais le jeton dans      // les journaux ; un échec d'envoi est journalisé, la réponse reste la même.      void sendPasswordResetEmail({ toEmail: user.email, token }).catch((err: unknown) => {        console.error(          "[auth] e-mail de réinitialisation non envoyé :",          err instanceof Error ? err.message : err,        );      });      return { ok: true as const };    }),  resetPassword: publicProcedure    .input(resetPasswordSchema)    .mutation(async ({ ctx, input }) => {      const token = input.token.trim();      const tokenHash = hashResetToken(token);      const entry = await ctx.prisma.passwordResetToken.findUnique({        where: { tokenHash },        select: {          id: true,          userId: true,          expiresAt: true,          usedAt: true,        },      });      if (!entry) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien n'est pas valide. Demandez-en un nouveau avec « Mot de passe oublié »." });      }      if (entry.usedAt) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." });      }      if (entry.expiresAt.getTime() < Date.now()) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a expiré. Demandez-en un nouveau avec « Mot de passe oublié »." });      }      const hashedPassword = await hashPassword(input.newPassword);      await ctx.prisma.$transaction(async (tx) => {        await tx.user.update({          where: { id: entry.userId },          // `isFirstLogin` n'est pas touché : choisir un mot de passe ne complète pas le profil.          // Un nouveau membre qui passe par « Mot de passe oublié » y reste invité.          data: { password: hashedPassword },        });        // Consommation atomique : la ligne n'est marquée que si elle ne l'était pas encore. Deux        // envois simultanés du même lien passent tous deux les vérifications ci-dessus ; un seul        // obtient ici count === 1, l'autre annule sa transaction.        const consumed = await tx.passwordResetToken.updateMany({          where: { id: entry.id, usedAt: null },          data: { usedAt: new Date() },        });        if (consumed.count !== 1) {          throw new TRPCError({ code: "BAD_REQUEST", message: "Ce lien a déjà servi. Connectez-vous, ou demandez-en un nouveau avec « Mot de passe oublié »." });        }        await tx.session.deleteMany({          where: { userId: entry.userId },        });      });      return { ok: true as const };    }),});

Le routeur auth orchestre. Il ne fait pas la logique métier : il la délègue.

Six procédures, et derrière elles un service de connexion, un module de session, un service de réinitialisation et un service d’envoi d’e-mails. Le routeur reste lisible parce qu’il porte les intentions, pas les implémentations.


👉 Où nous en sommes : le routeur est cartographié. Nous savons quelles procédures existent, et pourquoi chacune est publique ou protégée. Mais les procédures protégées reposent toutes sur une condition préalable : que ctx.sessionClient soit déjà reconstruit. Comment se produit cette reconstruction ? C’est la prochaine étape.


Le retour : reconstruire la session à chaque requête#

Voici le cœur battant du système.

À chaque requête entrante — un appel tRPC, une page rendue par le serveur, une route de fichiers — le backend doit répondre à la même question : « qui parle, maintenant ? »

C’est le rôle de getSessionFromRequest.

getSessionFromRequest : du cookie au SessionClient
TS
packages/backend/src/modules/auth/getSessionFromRequest.ts
export async function getSessionFromRequest(req?: Request): Promise<SessionClient | null> {  const payload = verifyJwt(await readAuthToken(req));  if (!payload) return null;  const sessionDB = await prisma.session.findUnique({    where: { id: payload.sessionId },    select: sessionSelect,  });  if (!sessionDB) return null;  if (sessionDB.expiresAt < new Date()) {    await prisma.session.deleteMany({      where: { id: sessionDB.id },    });    return null;  }  return mapSessionDBToSessionClient(sessionDB);}

Lecture guidée du retour de session#

Le voyage du cookie au SessionClient

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

readAuthToken : deux sources possibles
TS
packages/backend/src/modules/auth/getSessionFromRequest.ts
guided extract: lines 72-106focus lines 1-13
import { cookies } from "next/headers";import { prisma } from "@backend/prisma";import { COOKIE_NAME } from "@contracts/auth/constants";import type { Prisma } from "@prisma/client";import type { SessionClient } from "@contracts/auth/session.types";import { verifyJwt } from "@backend/lib/session/session.server";/** * Ce que la lecture d'une session ramène, et rien d'autre : la session, les champs du membre qui * partent vers le navigateur, et son appartenance au groupe Administrateurs (au plus une ligne). * Avec relationJoins (schema.prisma), Prisma lit le tout en UNE requête SQL. */const sessionSelect = {  id: true,  expiresAt: true,  user: {    select: {      id: true,      email: true,      firstName: true,      lastName: true,      pseudo: true,      avatar: true,      isFirstLogin: true,      memberGroupMemberships: {        where: { group: { isAdminGroup: true } },        select: { id: true },        take: 1,      },    },  },} satisfies Prisma.SessionSelect;/** Une session lue avec son membre : type GÉNÉRÉ par Prisma à partir de la sélection elle-même. */type SessionDB = Prisma.SessionGetPayload<{ select: typeof sessionSelect }>;/** De la ligne lue en base au contrat partagé avec le front : champ par champ, jamais la ligne entière. */function mapSessionDBToSessionClient(session: SessionDB): SessionClient {  const { memberGroupMemberships, ...user } = session.user;  return {    expiresAt: session.expiresAt,    user: {      ...user,      // Source de vérité « admin » : l'appartenance au groupe Administrateurs (isAdminGroup).      // Aucun champ de User ne le dit : le droit se lit dans les relations.      isAdmin: memberGroupMemberships.length > 0,    },  };}function getCookieFromHeader(cookieHeader: string | null, name: string): string | null {  if (!cookieHeader) return null;  const parts = cookieHeader.split(";").map((p) => p.trim());  for (const p of parts) {    if (!p) continue;    const eq = p.indexOf("=");    if (eq === -1) continue;    const k = p.slice(0, eq).trim();    const v = p.slice(eq + 1).trim();    if (k === name) return decodeURIComponent(v);  }  return null;}async function readAuthToken(req?: Request): Promise<string | null> {  if (req) {    const token = getCookieFromHeader(req.headers.get("cookie"), COOKIE_NAME);    if (token) return token;  }  try {    const cookieStore = await cookies();    return cookieStore.get(COOKIE_NAME)?.value ?? null;  } catch {    return null;  }}export async function getSessionFromRequest(req?: Request): Promise<SessionClient | null> {  const payload = verifyJwt(await readAuthToken(req));  if (!payload) return null;  const sessionDB = await prisma.session.findUnique({    where: { id: payload.sessionId },    select: sessionSelect,  });  if (!sessionDB) return null;  if (sessionDB.expiresAt < new Date()) {    await prisma.session.deleteMany({      where: { id: sessionDB.id },    });    return null;  }  return mapSessionDBToSessionClient(sessionDB);}

🔭 Pour aller plus loin — la double vérification (jeton + base)#

Pourquoi vérifier à la fois la signature du jeton et l’existence de la session en base ? À première vue, c’est redondant. Si le jeton est bien signé, pourquoi ne pas lui faire confiance ?

Trois scénarios concrets justifient la double vérification.

Scénario 1 — La déconnexion.
Un membre se déconnecte. Le backend supprime la ligne , le navigateur supprime le cookie. Mais imaginons qu’un intrus ait copié le jeton quelques secondes avant. Sans vérification en base, ce jeton resterait utilisable jusqu’à son expiration, sept jours plus tard. Avec elle, la recherche échoue dès la déconnexion : le jeton ne désigne plus rien.

Scénario 2 — Le changement de mot de passe.
Un membre change son mot de passe parce qu’il soupçonne qu’on l’a deviné. Le backend supprime toutes ses autres sessions (chapitre 6). À la requête suivante, l’intrus présente un jeton à la signature parfaite — mais la session qu’il désigne n’existe plus. Accès refusé.

Scénario 3 — La réinitialisation.
Même mécanisme quand le mot de passe est réinitialisé par « Mot de passe oublié » (chapitre 7) : toutes les sessions sont supprimées, sur tous les appareils.

Sans la double vérification, aucun de ces trois scénarios ne serait possible. Le jeton seul ne donne aucune révocabilité.


👉 Où nous en sommes : le jeton est vérifié, la session est retrouvée en base, l’expiration est contrôlée. Reste une dernière étape avant d’exposer cette session au reste du backend : la transformer en SessionClient.


La sélection et la projection : de la base au SessionClient#

Nous avons vu le contrat SessionClient. Voyons maintenant comment la donnée de Prisma est projetée vers ce contrat.

sessionSelect et mapSessionDBToSessionClient
TS
packages/backend/src/modules/auth/getSessionFromRequest.ts
/** * Ce que la lecture d'une session ramène, et rien d'autre : la session, les champs du membre qui * partent vers le navigateur, et son appartenance au groupe Administrateurs (au plus une ligne). * Avec relationJoins (schema.prisma), Prisma lit le tout en UNE requête SQL. */const sessionSelect = {  id: true,  expiresAt: true,  user: {    select: {      id: true,      email: true,      firstName: true,      lastName: true,      pseudo: true,      avatar: true,      isFirstLogin: true,      memberGroupMemberships: {        where: { group: { isAdminGroup: true } },        select: { id: true },        take: 1,      },    },  },} satisfies Prisma.SessionSelect;/** Une session lue avec son membre : type GÉNÉRÉ par Prisma à partir de la sélection elle-même. */type SessionDB = Prisma.SessionGetPayload<{ select: typeof sessionSelect }>;/** De la ligne lue en base au contrat partagé avec le front : champ par champ, jamais la ligne entière. */function mapSessionDBToSessionClient(session: SessionDB): SessionClient {  const { memberGroupMemberships, ...user } = session.user;  return {    expiresAt: session.expiresAt,    user: {      ...user,      // Source de vérité « admin » : l'appartenance au groupe Administrateurs (isAdminGroup).      // Aucun champ de User ne le dit : le droit se lit dans les relations.      isAdmin: memberGroupMemberships.length > 0,    },  };}

Lecture guidée de la sélection et de la projection#

De la sélection au contrat partagé

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Une sélection nommée
TS
packages/backend/src/modules/auth/getSessionFromRequest.ts
guided extract: lines 9-50focus lines 6-8
import { cookies } from "next/headers";import { prisma } from "@backend/prisma";import { COOKIE_NAME } from "@contracts/auth/constants";import type { Prisma } from "@prisma/client";import type { SessionClient } from "@contracts/auth/session.types";import { verifyJwt } from "@backend/lib/session/session.server";/** * Ce que la lecture d'une session ramène, et rien d'autre : la session, les champs du membre qui * partent vers le navigateur, et son appartenance au groupe Administrateurs (au plus une ligne). * Avec relationJoins (schema.prisma), Prisma lit le tout en UNE requête SQL. */const sessionSelect = {  id: true,  expiresAt: true,  user: {    select: {      id: true,      email: true,      firstName: true,      lastName: true,      pseudo: true,      avatar: true,      isFirstLogin: true,      memberGroupMemberships: {        where: { group: { isAdminGroup: true } },        select: { id: true },        take: 1,      },    },  },} satisfies Prisma.SessionSelect;/** Une session lue avec son membre : type GÉNÉRÉ par Prisma à partir de la sélection elle-même. */type SessionDB = Prisma.SessionGetPayload<{ select: typeof sessionSelect }>;/** De la ligne lue en base au contrat partagé avec le front : champ par champ, jamais la ligne entière. */function mapSessionDBToSessionClient(session: SessionDB): SessionClient {  const { memberGroupMemberships, ...user } = session.user;  return {    expiresAt: session.expiresAt,    user: {      ...user,      // Source de vérité « admin » : l'appartenance au groupe Administrateurs (isAdminGroup).      // Aucun champ de User ne le dit : le droit se lit dans les relations.      isAdmin: memberGroupMemberships.length > 0,    },  };}function getCookieFromHeader(cookieHeader: string | null, name: string): string | null {  if (!cookieHeader) return null;  const parts = cookieHeader.split(";").map((p) => p.trim());  for (const p of parts) {    if (!p) continue;    const eq = p.indexOf("=");    if (eq === -1) continue;    const k = p.slice(0, eq).trim();    const v = p.slice(eq + 1).trim();    if (k === name) return decodeURIComponent(v);  }  return null;}async function readAuthToken(req?: Request): Promise<string | null> {  if (req) {    const token = getCookieFromHeader(req.headers.get("cookie"), COOKIE_NAME);    if (token) return token;  }  try {    const cookieStore = await cookies();    return cookieStore.get(COOKIE_NAME)?.value ?? null;  } catch {    return null;  }}export async function getSessionFromRequest(req?: Request): Promise<SessionClient | null> {  const payload = verifyJwt(await readAuthToken(req));  if (!payload) return null;  const sessionDB = await prisma.session.findUnique({    where: { id: payload.sessionId },    select: sessionSelect,  });  if (!sessionDB) return null;  if (sessionDB.expiresAt < new Date()) {    await prisma.session.deleteMany({      where: { id: sessionDB.id },    });    return null;  }  return mapSessionDBToSessionClient(sessionDB);}

La sélection n’est pas une formalité technique.

C’est la garantie active que le backend ne laisse jamais sortir d’information par accident : ce qui n’est pas demandé à la base ne peut pas partir vers le navigateur.


👉 Où nous en sommes : le SessionClient est prêt, construit à partir de la base et projeté contre le contrat partagé. Il ne reste plus qu’à le placer à la disposition du reste du backend — dans le contexte tRPC.

À suivre#

Le SessionClient est prêt. Reste à le mettre à la disposition de chaque procédure, et à voir ce que cela change pour le code métier.

Chapitre suivant : la session dans le backend. createTRPCContext, protectedProcedure, et le code « confiant » qu’ils rendent possible.

Sur cette page