Introduction

Lorsqu’on crée une API se pose systématiquement la question des mesures d’authentification que l’on va utiliser. Une partie du sujet concerne les passes, que l’on verra plus tard, et l’autre se porte sur comment un utilisateur connecté peut prouvé qu’il est connecté, sans devoir décliner ses identifiants à nouveaux.

Deux grandes méthodes pour prouver que l’on est connecté existent :

  • les jetons de session ;
  • les JWT JSON Web Tokens.

Théorie

Jetons de session

Il s’agit d’une chaîne de caractères qui est donnée au client à la connexion. La chaîne est restituée à chaque requête. Le plus souvent elle est stockée en cookie.

Le serveur vérifie dans une base de donnée s’il connait la chaîne et à quel utilisateur elle correspond.

Elle peut être utilisée pour se souvenir d’un état (ce qui aujourd’hui est considéré comme une mauvaise pratique.)

Elle est cryptographique (j’espère pour vous) (c’est à dire qui a été générée d’une façon où elle ne peut pas être déduite par un attaquant)

Avec un jeton de session la déconnexion se fait :

  • par la suppression du jeton côté client ; et
  • par la demande au serveur d’oublier cette session en la supprimant de sa base de données.

Cookies

Un navigateur distingue plusieurs types de requêtes :

  • principale same-site :
    • la requête principale,
    • vous avez saisi vous-même l’URL ou vous étiez déjà sur le site lors de la requête principale précédente,
    • le GET ou le POST qui est fait sur l’adresse qui est dans la bare d’adresse ;
  • principale cross-site :
  • XHR same-site :
    • les requêtes XHR qui sont provoquées par le javascript sur le même site que la requête principale
    • (qui jouissent des mêmes droits que la requête principale) ;
  • XHR cross-site :
    • les requêtes XHR sur un autre site que la requête principale.

Dans le cadre d’une requête HTTP, le serveur peut demander au client de se souvenir de données textuelles et de lui restituer lors des requêtes futures. Il s’agit des cookies.

Les cookies associent une clé textuelle avec une valeur textuelle, à la façon d’un dictionnaire, d’une map.

Le comportement des navigateurs avec les cookies est :

  • de se souvenir des cookies que les serveurs demandent de retenir ;
  • de supprimer les cookies que les serveurs demande de supprimer ;
  • d’ajouter aux requêtes futures vers le même site les cookies qu’ils ont en mémoire.

Les navigateurs suivent leur comportement par défaut seulement lors des requêtes principales same-site et cross-site.

Les cas spéciaux sont :

  • le site de la requête principale est différent de celui de la requête XHR (XHR cross-site) :
    • il faut que le cookie soit configuré en SameSite: None et Secure,
    • il faut que le CORS autorise les credentials et l’origine de la requête principale (les jokers étant interdis),
    • il faut que la requête soit faite avec les credentials activés (pour envoyer comme pour accepter de recevoir un cookie),
    • c’est un cas possible quand on travaille avec une API qui n’est pas accessible derrière le même reverse-proxy que le frontend mais pas plus fréquent que l’usage d’un bearer token ;
  • votre requête principale est le fruit d’une redirection depuis un autre site (principale cross-site) :
    • il faut que la requête soit en GET (considérée sûre, ne pouvant pas modifier l’état de l’application ciblée),
    • il faut que le cookie soit configuré en SameSite: Lax ou SameSite: None ;
  • votre cookie est considéré comme un cookie de pistage par le navigateur :
    • il faut que la sécurité anti-pistage soit désactivée par l’utilisateur (n’y comptez pas, mais ça ne devrait pas vous arriver.)

Sécurité des cookies

On donnera quasiment systématiquement l’attribut Secure aux cookies, qui contraint le client à ne renvoyer le cookie que si la connexion est sécurisée en HTTPS.

Pour éviter les attaques par injection côté frontend, on va aussi donner l’attribut HttpOnly aux cookies. Celui-ci fait en sorte que le javascript du site ne puisse pas lire le cookie, rendant impossible sa fuite autrement qu’en compromettant le navigateur directement.

JWT

À la façon des jetons de session, les JWT sont échangés auprès d’un service d’authentification contre des identifiants valides. Ils peuvent être stockés dans un cookie. Ou dans le localStorage et ajouté à chaque requête dans un champ Authorization.

Les JWT sont intéressants parce qu’ils permettent deux choses :

  • se connecter auprès d’un serveur A (qui va signer le jeton) et pouvoir prouver au serveur B que nous sommes connectés (en vérifiant la signature grâce à une clé publique) ; et
  • encoder des informations “de session” directement dans le jeton pour limiter les lectures en base de donnée.

Dans notre cas, pas besoin de serveur tiers pour l’authentification, donc pas de clés asymétrique, on gardera notre seule clé (la clé privée) bien secrète.

Vous pouvez regarder à quoi ressemble un JWT sur le site https://www.jwt.io/.

Pratique : JWT

Bibliothèques:

  • jose pour générer et valider des JWT avec l’algorithme HS256 ;
  • node:crypto pour générer notre secret de 256 bits ;

Génération du secret

import crypto from "node:crypto";

function generateSecretBytes(bits = 256): Uint8Array {
  const bytes = new Uint8Array(bits / 8);
  crypto.getRandomValues(bytes);
  return bytes;
}

function toBase64url(bytes: Uint8Array): string {
  const base64 =
    typeof Buffer !== "undefined"
      ? Buffer.from(bytes).toString("base64")
      : btoa(String.fromCharCode(...bytes));
  return base64.replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}

const secret = generateSecretBytes();
console.log(toBase64url(secret));

Vous pouvez mettre le secret généré dans votre .env sous JWT_SECRET

Classe de gestion des tokens

L’idéal c’est de réunir toutes les opérations en rapport avec les jetons sur une même classe, à commencer par le chargement du secret. jose s’attend à ce qu’on lui passe le secret en tableau de nombre entier de 8 octets, donc on doit décoder la variable d’environnement :

export class TokenManager {
  #secret: Uint8Array;

  constructor(secret_str?: string | Uint8Array) {
    const src = secret_str ?? process.env?.JWT_SECRET;
    if (!src) {
      throw new Error("JWT_SECRET is not set and no secret was provided on construction");
    }
    if (src instanceof Uint8Array) this.secret = src;
    else this.secret = fromBase64url(src);
  }
}

function fromBase64url(source: string): Uint8Array {
  const base64 = source.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64 + "===".slice((base64.length + 3) % 4);
  if (typeof Buffer !== "undefined") {
    return new Uint8Array(Buffer.from(padded, "base64"));
  }
  const bin = atob(padded);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

Enfin pour représenter nos claims on peut déclarer deux types et pour vérifier et signer on peut ajouter ces deux fonctions à la classe :

import { jwtVerify, SignJWT } from "jose";

type JwtClaims = {
  sub: string,
  roles: string[],
  iat: number,
  exp: number,
};

// on creation, iat and exp are set automatically, we don't need to receive them:
type CreateJwtOption = Omit<JwtClaims, "iat" | "exp">;

export class TokenManager {
  // [...]
  async encode(claims: CreateJwtOptions): Promise<string> {
    const { sub, roles } = claims;

    const token = await new SignJWT({ roles })
      .setProtectedHeader({ alg: "HS256", typ: "JWT" })
      .setSubject(sub)
      .setIssuedAt()
      .setExpirationTime("1h")
      .sign(this.secret);
    return token;
  }

  async verify(encoded_token: string): Promise<JwtClaims> {
    const { payload } = await jwtVerify(encoded_token, this.secret, {
      algorithms: ["HS256"],
    });

    return payload;
  }
}

Notez que lors de l’appel à jwtVerify sont vérifiés :

  • le payload contre la signature ;
  • que la date iat est déjà passée ; et
  • que la date exp n’est pas encore passée.

Pratique : décoration et cookie

Décoration de l’instance fastify

À partir du moment où l’on déclare nos routes et nos hooks dans la fonction start_web_server, les handlers peuvent capturer les objets déclarés dans start_web_server. Le fait de capturer les dépendances des routes peut poser de problèmes de modularité du code, mais on va le faire parce que c’est facile.

Reprenons la fonction start_web_server que nous avions fait dans le TP précédent et :

  • ajoutons-y une instance de token_manager ;
  • le plugin @fastify/cookie (que vous devez ajouter avec pnpm) ;
  • un handler qui produit un token d’exemple et l’ajoute dans un cookie (et qui utilise date-fns pour générer la date expiration du cookie).
import Fastify, * as ff from "fastify";
import { type JwtClaims, TokenManager } from "./token.js";
import { addHours } from "date-fns";

function start_web_server() {
  let web_server = Fastify({logger: true});
  let token_manager = new TokenManager();
  web_server.register(cookie, {});

  web_server.get("/token", async (req, res) => {
    const tok = await token_manager.encode({
      sub: "example",
      roles: ["regular", "admin"],
    });
    res.setCookie("access_token", tok, {
      secure: true,
      sameSite: false,
      expires: addHours(new Date(), 1),
    });
    res.status(204);
  });

  // [appel pour listen]
}

Si vous appelez cette route, vous devriez voir un header : Set-Cookie. Si vous testez avec httpie, faites votre appel avec --session=./session.json pour que le cookie soit conservé, pour de prochaines requêtes.

> http --session=./session.json :1234/token
HTTP/1.1 204 No Content
Connection: keep-alive
Date: Fri, 29 Aug 2025 14:24:36 GMT
Keep-Alive: timeout=72
set-cookie: access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJyZWd1bGFyIiwiYWRtaW4iXSwic3ViIjoiZXhhbXBsZSIsImlhdCI6MTc1NjQ3NzQ3NiwiZXhwIjoxNzU2NDgxMDc2fQ.VOcq1b4dITKA9X2ut-nyAW_sG330dFV2nhdZUvR0uV0; Expires=Fri, 29 Aug 2025 15:24:36 GMT; Secure

La requête suivante avec --verbose et --session :

> http --session=./session.json --verbose :1234/
GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, zstd
Connection: keep-alive
Cookie: access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJyZWd1bGFyIiwiYWRtaW4iXSwic3ViIjoiZXhhbXBsZSIsImlhdCI6MTc1NjQ3NzQ3NiwiZXhwIjoxNzU2NDgxMDc2fQ.VOcq1b4dITKA9X2ut-nyAW_sG330dFV2nhdZUvR0uV0
Host: localhost:1234
User-Agent: HTTPie/3.2.4



HTTP/1.1 200 OK
Connection: keep-alive
Date: Fri, 29 Aug 2025 14:25:29 GMT
Keep-Alive: timeout=72
content-length: 27
content-type: application/json; charset=utf-8

{
    "message": "hello example"
}

Pour le serveur, on peut récupérer le cookie que nous renvoie le client avec un expression comme celle-ci : req.cookie.my_cookie.

Écrivez une route GET /claims qui lit le cookie, le vérifie à l’aide du token_manager, et répond avec les claims qui étaient encodées dans le jeton.

Exercice : hook de vérification

Si vous deviez vérifier le jeton et le cookie dans chacune de vos routes, ça serait très répétitif. Heureusement fastify a un outil qui peut nous aider, les hooks.

  // [...]
  const web_server = Fastify({ logger: true });
  const token_manager = new TokenManager();
  web_server.register(cookie, {});

  web_server.addHook("preHandler", async (req, res) => {
    // [contenu que vous devez écrire]
    req.my_field = some_value;
  });
  // [...]

Dans un hook pre handler vous pouvez faire tout ce que vous pouvez faire dans un handler. Comme votre objectif est de parser les claims et les rendre disponible pour les handlers, vous allez pouvoir ajouter un champ à la requête req.

Pour que typescript sache de quel type est le champ que vous allez ajouter, vous devez avoir dans l’étendue générale la déclaration suivante (en modifiant le nom du champ et son type) :

declare module "fastify" {
  interface FastifyRequest {
    my_field: MyObjectType,
  }
}

Exercice : vérification du role

Écrivez une fonction requireRole qui renvoie un pre-handler qui s’assure de la présence d’un rôle sur les claims qui ont été parsées par votre pre-handler précédent.

Elle doit s’utiliser ainsi :

  web_server.get("/admin", {preHandler: requireRole("admin")}, async (req) => {
    return { message: `hello admin ${req.claims.sub}`};
  });

Si le client est connecté mais que le rôle demandé n’est pas présent, renvoyez l’erreur HTTP appropriée.

Théorie : CORS

CORS ou Cross Origin Resource Sharing est un mécanisme de sécurité utilisé par les navigateurs web pour empêcher n’importe quel site web de faire des requêtes XHR cross-site vers votre API.

Si votre API ne renvoie aucun header CORS lors des requêtes qui lui sont faites, alors les navigateurs web appliquerons les règles les plus strictes, c’est à dire : un refus catégorique d’honorer la requête si elle est faite en XHR cross-site. Sauf que ce comportement, c’est la hantise des développeurs qui ne le comprennent pas.

En quoi ça nous concerne ? Lorsque vous allez implémenter un frontend vous allez devoir le servir sur un port différent de votre backend. Un port différent c’est un site, ou une origine différente au regard du navigateur. Cela veut dire que votre frontend n’auras pas le droit de faire des requêtes vers votre backend.

Deux solutions :

  • on fait un proxy qui sert le frontend et redirige les requêtes faites sur /api vers le backend, ainsi sur un même port on trouve le frontend et le backend ; ou
  • on utilise CORS pour indiquer au navigateur qu’il a le droit d’envoyer des requêtes à notre backend depuis notre frontend.

Pour que le navigateur accepte d’envoyer les requêtes en XHR cross-site, il faut que le backend renvoie sous forme de header :

  • les origines autorisées ;
  • les méthodes HTTP autorisées ;
  • si les credentials (les cookies) sont autorisés.

En CORS ça s’exprime :

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:1234
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600

Pour finir : vous n’avez pas besoin d’ajouter un handler OPTIONS pour toutes vos routes existantes. Vous n’avez pas besoin non plus d’ajouter vous-même les headers qui constituent le CORS. De nos jours on utilise presque systématiquement une bibliothèque.

Pratique : plugin CORS

Configurer CORS pour fastify c’est relativement simple.

pnpm add @fastify/cors

Dans vos imports :

import cors from "@fastify/cors";

Dans start_web_server :

  web_server.register(cors, {
    origin: "http://localhost:1234",
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    credentials: true,
    allowedHeaders: ["Content-Type", "Authorization"],
  });

Cette configuration est adaptée au développement local avec un frontend sur le port 1234. Bien entendu elle est a modifier si votre environnement de travail est différent ou pour la production.

Host: localhost:3000 Origin: http://localhost:1234 Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type, Authorization Accept: / Content-Length: 0

Conclusion

À la fin de ce TP vous êtes capable d’emettre et de contrôler des JWT mais pas encore de permettre à des utilisateurs de ce connecter avec un mot de passe.

Aussi vos jetons sont des jetons d’accès, qui ne peuvent pas être révoqués étant donné que vous ne consultez pas la base de donnée lorsque vous les vérifiez. Pour contre balancer l’impossibilité des les révoquer les JWT d’accès ont une faible durée de vie et sont souvent apperrées à des jetons de renouvellement qui sont eux stockés en base de données. Il sera intéressant des les étudier plus tard, ils sont nécessaires à une solution de sécurité complète.