Sessions

Le point d'entrée @bext-stack/framework/auth fournit createSession : une session tenue entièrement dans un cookie signé (HMAC-SHA256) et daté. Le serveur ne stocke rien : le cookie porte les données, leur date d'émission et leur date d'expiration, et la signature empêche de les modifier. La signature est calculée en JavaScript pur, sans node:crypto ni WebCrypto, et fonctionne donc dans tous les loaders, actions et routes.

Créer une session

Déclarez la session une fois dans un module partagé, puis importez-la dans vos routes :

// src/lib/session.ts
import { createSession } from "@bext-stack/framework/auth";

export const session = createSession<{ userId: string; role: string }>({
  secret: process.env.SESSION_SECRET!,
  cookie: "myapp_session",   // default "bext_session"
  maxAgeSecs: 7 * 24 * 3600, // default 86400 (1 day)
});
Option Défaut Rôle
secret (obligatoire) Clé de signature HMAC-SHA256
cookie "bext_session" Nom du cookie
maxAgeSecs 86400 (1 jour) Durée de validité, inscrite dans les données signées et reprise comme Max-Age du cookie
sameSite "Lax" "Lax", "Strict" ou "None"
secure true Passez false pour retirer l'attribut Secure
path "/" Attribut Path du cookie

Le cookie est toujours HttpOnly : le JavaScript de la page ne peut pas le lire. Le secret se lit dans process.env (voir Secrets) : ne l'écrivez jamais dans le code.

API

createSession<T>(options) retourne un objet Session<T> :

Membre Description
read(request) Lit le cookie de la requête, vérifie la signature et l'expiration. Retourne les données, ou null si le cookie est absent, modifié ou expiré.
cookie(data) Retourne la valeur d'un en-tête Set-Cookie qui ouvre la session avec data.
clearCookie() Retourne la valeur d'un en-tête Set-Cookie qui efface le cookie (Max-Age=0).
issue(data) Retourne le jeton signé seul, sans cookie.
verify(token) Vérifie un jeton produit par issue. Retourne les données ou null.
cookieName Le nom du cookie utilisé.

read et verify retournent les données telles que vous les avez passées : les champs iat et exp ajoutés à l'émission sont retirés.

Exemple : connexion et déconnexion

Une page de connexion dont l'action vérifie le mot de passe, puis ouvre la session :

// src/app/login/page.tsx
import { password } from "@bext-stack/framework/password";
import { session } from "@/lib/session";

export const dynamic = "force-dynamic";

export function loader({ request }) {
  // Already signed in: skip the form.
  if (session.read(request)) {
    throw new Response(null, { status: 303, headers: { Location: "/compte" } });
  }
  return {};
}

export async function action({ request }) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "");
  const user = await findUserByEmail(email); // your own lookup
  const ok = user && (await password.verify(String(form.get("password") ?? ""), user.passwordHash));
  if (!ok) return { error: "Email ou mot de passe incorrect." };

  return new Response(null, {
    status: 303,
    headers: {
      Location: "/compte",
      "Set-Cookie": session.cookie({ userId: user.id, role: user.role }),
    },
  });
}

export default function Page({ actionData }) {
  return (
    <form method="post">
      {actionData?.error && <p>{actionData.error}</p>}
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Se connecter</button>
    </form>
  );
}

Une page protégée lit la session dans son loader et gère la déconnexion dans son action :

// src/app/compte/page.tsx
import { session } from "@/lib/session";

export const dynamic = "force-dynamic";

export function loader({ request }) {
  const user = session.read(request);
  if (!user) throw new Response(null, { status: 303, headers: { Location: "/login" } });
  return { user };
}

export function action() {
  // Logout: clear the cookie and go back to the login page.
  return new Response(null, {
    status: 303,
    headers: { Location: "/login", "Set-Cookie": session.clearCookie() },
  });
}

export default function Page({ data }) {
  return (
    <form method="post">
      <p>Connecté : {data.user.userId}</p>
      <button type="submit">Se déconnecter</button>
    </form>
  );
}

Le composant de page ne voit pas request : lisez toujours la session dans le loader ou l'action, et passez à la page ce qu'elle doit afficher (voir Loaders & Actions).

Expiration

  • L'expiration est fixe : exp vaut l'heure d'émission plus maxAgeSecs. Lire la session ne la prolonge pas. Pour la prolonger, renvoyez un nouveau session.cookie(data) dans une réponse, par exemple à la fin d'une action.
  • Elle est vérifiée côté serveur : un jeton copié depuis le navigateur et rejoué après exp est refusé, même si le cookie n'a pas été effacé.
  • Changer maxAgeSecs ne touche que les sessions émises ensuite.

À savoir

  • Signé, pas chiffré. Les données sont du JSON encodé en base64url, lisible par quiconque a le cookie. N'y mettez qu'un identifiant et ce qui sert à décider vite (un rôle, une langue), jamais un mot de passe, un jeton tiers ou des données personnelles. Gardez-les courtes : elles voyagent dans chaque requête.
  • Pas de révocation côté serveur. clearCookie() efface le cookie du navigateur, mais un jeton copié reste valide jusqu'à son exp. Changer secret invalide toutes les sessions d'un coup. Pour déconnecter un seul utilisateur, stockez un numéro de version dans votre base, mettez-le dans la session et comparez-le dans le loader.
  • Un secret long et aléatoire. Toute personne qui connaît le secret peut fabriquer une session valide.
  • Données JSON. Les données passent par JSON.stringify : une Date revient sous forme de chaîne. N'utilisez pas iat ni exp comme noms de champs, ils sont réservés.
  • Type des données. T doit être un objet à clés chaînes. Un alias de type ou un type littéral ({ userId: string }) convient ; une interface doit étendre Record<string, unknown>.
  • Nom du cookie et cache. En mode ISR, les requêtes qui portent un cookie de session ne passent pas par le cache de pages. Un nom qui se termine par session (comme bext_session ou myapp_session) est reconnu comme tel ; avec un autre nom, ou par prudence, ajoutez export const dynamic = "force-dynamic" aux pages qui dépendent de la session (voir Routage & Rendu).
  • Mêmes réglages pour effacer. clearCookie() reprend cookie, path, sameSite et secure de la session : utilisez le même objet session pour ouvrir et fermer.
  • SameSite=None exige Secure dans les navigateurs : ne combinez pas sameSite: "None" avec secure: false.

Autres utilitaires

Le même point d'entrée exporte les briques utilisées par createSession :

Fonction Rôle
parseCookies(header) Transforme un en-tête Cookie en objet { nom: valeur } (valeurs décodées).
serializeCookie(name, value, options?) Construit une valeur Set-Cookie. Options : path ("/"), maxAge, domain, sameSite ("Lax"), secure (true), httpOnly (true).
createSigner(secret) Retourne { sign(payload), verify(payload, signature) } en HMAC-SHA256, signature en base64url.
timingSafeEqual(a, b) Compare deux chaînes en temps constant.

serializeCookie est utile pour un cookie qui n'est pas une session (préférence, panier) ou pour fixer domain, que createSession ne propose pas.

Et ensuite

Page Ce qu'elle couvre
Loaders & Actions Où lire la requête, redirections par Response
Secrets Fournir SESSION_SECRET à votre application
Routage & Rendu Cache de pages, force-dynamic