Cours NestJS · Module 4: Authentification

JWT Tokens - les laissez-passer romains

7 min de lecture
Dans cette leçon6

Le légionnaire a donné le mot de passe à la porte et il est entré. Une heure plus tard, il revient chercher des vivres - et quoi, il redonne le mot de passe ? Et à la troisième porte ? Vérifier un mot de passe à chaque étape signifie que le serveur doit aller chercher l'utilisateur en base à chaque requête. Des centaines de fois par minute, pour la même chose.

Dans ces cas-là, les Romains délivraient un laissez-passer : un document énumérant les privilèges du porteur, muni d'un sceau que toute sentinelle sait reconnaître. La sentinelle ne court pas aux archives - elle regarde le sceau et sait que le document est authentique. Sur le web, ce laissez-passer s'appelle un JWT, pour JSON Web Token.

Les trois parties du laissez-passer

Un JWT ressemble à une chaîne de caractères séparée par deux points. Ces points ne sont pas fortuits - ils le divisent en trois parties, toujours dans le même ordre :

1eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJyb2xlIjoiY2VudHVyaW9uIn0.dBjftJeZ4CVP
2   <-- Header -->      <----- Payload ----->                  <- Signature ->

Le Header indique avec quel algorithme le document a été signé. Le Payload est le contenu du laissez-passer - les données sur le légionnaire, appelées claims : son identifiant, son rôle, sa date d'expiration. La Signature est le sceau : le résultat de la signature des deux premières parties avec la clé secrète du serveur.

Voici maintenant le point le plus important et le plus souvent confondu : le payload est encodé, pas chiffré. Quiconque intercepte le token peut en lire le contenu - c'est du simple Base64, pas un secret. Le sceau ne cache pas les données, il prouve que personne ne les a échangées, car sans la clé secrète il est impossible à contrefaire. Il n'en découle qu'une seule conclusion pratique : ne mets jamais de mot de passe ni de données sensibles dans le payload.

Configuration - délivrer des laissez-passer

Nous ajoutons la capacité de délivrer des laissez-passer par un module :

1JwtModule.registerAsync({
2  imports: [ConfigModule],
3  inject: [ConfigService],
4  useFactory: (configService: ConfigService) => ({
5    secret: configService.get('JWT_SECRET'),
6    signOptions: {
7      expiresIn: '24h',
8      issuer: 'LegionaryFleet',
9    },
10  }),
11});

secret est la clé secrète avec laquelle le serveur scelle les tokens et les vérifie ensuite. Nous la lisons via ConfigService depuis une variable d'environnement, écrite dans le fichier .env sous la forme JWT_SECRET=super-secret-key - nom, signe égal, valeur, sans guillemets ni espaces. Une clé écrite dans le code atterrirait dans le dépôt, et de là entre les mains d'autrui ; qui détient la clé peut se délivrer un laissez-passer de centurion.

Nous utilisons registerAsync plutôt que le simple register, car la valeur de la clé n'est pas connue au moment d'écrire le code - il faut d'abord la lire depuis la configuration. D'où useFactory : une fonction que NestJS appellera une fois ConfigService disponible.

Dans signOptions, nous décrivons le laissez-passer lui-même. expiresIn: '24h' est sa durée de vie - après un jour, le document perd sa validité et le légionnaire doit se reconnecter. Cette limite est délibérée : si le token fuitait, le voleur ne dispose que de ce temps. issuer est l'émetteur, inscrit dans le payload.

La forme du laissez-passer

Il vaut la peine de décrire par un type ce que nous mettons exactement dans le payload :

1export interface JwtPayload {
2  sub: number;
3  username: string;
4  email: string;
5  role: string;
6  iat: number;
7  exp: number;
8}

Trois de ces noms sont des abréviations fixées par le standard JWT, d'où leur allure étrange. sub signifie subject - l'identifiant de celui que concerne le laissez-passer, c'est-à-dire simplement l'id de l'utilisateur. iat signifie issued at, le moment de l'émission. exp signifie expiration, le moment de péremption - c'est lui que surveille la vérification temporelle. Les champs iat et exp sont ajoutés par la bibliothèque elle-même ; username, email et role sont tes propres claims.

Délivrer le laissez-passer à la connexion

Quand le mot de passe correspond, le service signe un token et le remet au client :

1async login(user: User) {
2  const payload = {
3    sub: user.id,
4    username: user.username,
5    role: user.role,
6  };
7
8  return {
9    access_token: this.jwtService.sign(payload),
10  };
11}

jwtService.sign() prend ton objet, y ajoute iat et exp selon signOptions, et scelle le tout avec la clé secrète. La chaîne retournée est un laissez-passer achevé. Note ce qui est absent ici : le serveur ne retient rien. Aucune ligne de session n'est créée - tout le savoir sur le légionnaire voyage dans le token lui-même. Voilà la différence entre un laissez-passer et une liste d'invités à la porte.

Le cycle complet ressemble donc à ceci : le client envoie identifiant et mot de passe, le serveur les vérifie en base, le serveur génère un token, le client l'attache à chaque requête suivante dans l'en-tête Authorization, et le serveur décode le token et autorise la requête - sans aller chercher de mot de passe en base.

La sentinelle des laissez-passer

Qui vérifie donc le sceau à chaque requête ? Une stratégie s'en charge - une classe dans laquelle tu décris où prendre le token et comment le vérifier. Tu découvriras le système complet des stratégies dans la prochaine leçon ; pour l'instant, celle-ci, dédiée aux laissez-passer, suffit :

1@Injectable()
2export class JwtStrategy extends PassportStrategy(Strategy) {
3  constructor(private configService: ConfigService) {
4    super({
5      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
6      ignoreExpiration: false,
7      secretOrKey: configService.get('JWT_SECRET'),
8    });
9  }
10
11  async validate(payload: JwtPayload) {
12    return { userId: payload.sub, username: payload.username, role: payload.role };
13  }
14}

Les trois options de super() décrivent comment la sentinelle lit le document. jwtFromRequest indique où prendre le token - ExtractJwt.fromAuthHeaderAsBearerToken() l'extrait de l'en-tête Authorization: Bearer <token>, l'emplacement le plus courant. secretOrKey est la même clé que celle ayant servi à signer le token ; sans elle, impossible de vérifier le sceau.

ignoreExpiration: false prête à confusion par sa double négation, alors lisons-la franchement : n'ignore pas l'expiration, c'est-à-dire vérifie exp et rejette les tokens périmés. C'est le réglage que tu veux. Le mettre à true signifierait qu'un laissez-passer vieux d'un an ouvre encore les portes.

La méthode validate() reçoit un payload déjà vérifié - le sceau a été contrôlé, la date de validité aussi, avant que ton code ne s'exécute. L'objet retourné atterrit dans request.user et devient accessible dans le contrôleur. Note qu'il n'y a ici aucune requête en base : toutes les données nécessaires sont arrivées dans le token, et c'est précisément l'économie pour laquelle nous avons introduit les laissez-passer.

Récapitulatif

Le légionnaire porte un laissez-passer et ne donne plus le mot de passe à chaque porte :

  • un JWT se compose de trois parties dans un ordre fixe : Header, Payload, Signature,
  • le payload est encodé, pas chiffré - tout le monde peut le lire, donc n'y mets aucun mot de passe,
  • la signature ne cache pas les données, elle prouve que personne ne les a échangées,
  • JwtModule.registerAsync avec useFactory permet de tirer secret de ConfigService ; la clé vit dans .env, jamais dans le code,
  • signOptions.expiresIn fixe la durée de vie du token, issuer l'émetteur,
  • claims standard : sub (id de l'utilisateur), iat (émis le), exp (expire le),
  • jwtService.sign(payload) délivre le laissez-passer, et le serveur ne retient rien,
  • dans JwtStrategy : ExtractJwt.fromAuthHeaderAsBearerToken() lit le token dans l'en-tête Bearer, ignoreExpiration: false rejette les périmés, et validate() reçoit le payload déjà vérifié.

Dans la prochaine leçon, tu découvriras Passport.js comme système complet de stratégies - tu verras que la sentinelle des laissez-passer n'en est qu'une parmi beaucoup. En attendant, retiens ceci : un JWT est un laissez-passer scellé - chacun peut lire ce qu'il dit, mais personne ne peut le contrefaire sans la clé secrète.

Code de cette leçon : src/auth/jwt-config.ts
1// Tokens JWT - les laissez-passer romains
2// Chaque laissez-passer se compose de 3 parties : Header, Payload, Signature
3import { Module } from '@nestjs/common';
4import { JwtModule, JwtService } from '@nestjs/jwt';
5import { ConfigModule, ConfigService } from '@nestjs/config';
6import { Injectable } from '@nestjs/common';
7
8// ===========================================
9// 1. Configuration du JWT Module
10// ===========================================
11
12@Module({
13  imports: [
14    JwtModule.registerAsync({
15      imports: [ConfigModule],
16      useFactory: async (configService: ConfigService) => ({
17        // Secret pour signer les tokens
18        secret: configService.get<string>('JWT_SECRET') || 'spqr-secret-key',
19        signOptions: {
20          expiresIn: '24h',      // Token valable 24 heures
21          issuer: 'imperium-romanum', // Émetteur du token
22          algorithm: 'HS256',    // Algorithme de signature
23        },
24      }),
25      inject: [ConfigService],
26    }),
27  ],
28  exports: [JwtModule],
29})
30export class JwtConfigModule {}
31
32// ===========================================
33// 2. Service de gestion des tokens
34// ===========================================
35
36@Injectable()
37export class TokenService {
38  constructor(private jwtService: JwtService) {}
39
40  // Créer un access token
41  createAccessToken(legionary: { id: string; username: string; rank: string }) {
42    const payload = {
43      sub: legionary.id,
44      username: legionary.username,
45      rank: legionary.rank,
46      type: 'access',
47    };
48    return this.jwtService.sign(payload, { expiresIn: '1h' });
49  }
50
51  // Créer un refresh token
52  createRefreshToken(legionary: { id: string }) {
53    const payload = {
54      sub: legionary.id,
55      type: 'refresh',
56    };
57    return this.jwtService.sign(payload, { expiresIn: '7d' });
58  }
59
60  // Vérification du token
61  verifyToken(token: string) {
62    try {
63      const decoded = this.jwtService.verify(token);
64      console.log('Token décodé :', decoded);
65      return decoded;
66    } catch (error) {
67      console.log('Token invalide ou expiré !');
68      throw error;
69    }
70  }
71
72  // Décodage sans vérification (par ex. pour lire le payload)
73  decodeToken(token: string) {
74    return this.jwtService.decode(token);
75  }
76}
77
78// ===========================================
79// 3. Structure du token JWT
80// ===========================================
81
82console.log('=== Structure du JWT ===');
83console.log('Header:  { alg: "HS256", typ: "JWT" }');
84console.log('Payload: { sub: "1", username: "Caesar", rank: "consul" }');
85console.log('Signature: HMACSHA256(header + payload, secret)');
86console.log('');
87console.log('Token = base64(header).base64(payload).signature');
88console.log('Access Token : courte durée de vie (1h)');
89console.log('Refresh Token : longue durée de vie (7d)');
90

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. De combien de parties un token JWT est-il composé ?

  2. 2. Que contient la section Payload d'un token JWT ?

Voici 2 des 5 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Complétez la configuration de JwtModule en définissant le secret depuis ConfigService, la durée d'expiration à '24h' et l'issuer à 'LegionaryFleet'

  • Tri horizontal

    Placez les parties d'un token JWT de gauche à droite (telles qu'elles apparaissent dans la chaîne)

  • Éditeur de code

    Complétez la classe JwtStrategy qui étend PassportStrategy(Strategy) et implémente une méthode validate vérifiant si l'utilisateur existe

  • Clique dans l'ordre

    Placez les éléments de la déclaration de la classe JwtStrategy dans le bon ordre

  • Tri vertical

    Ordonnez les étapes du processus de connexion avec JWT

  • Tri horizontal

    Placez la syntaxe de définition de la variable JWT_SECRET dans un fichier .env

  • Éditeur de code

    Créez une interface JwtPayload en TypeScript contenant les champs : sub (number), username (string), email (string), role (string), iat (number) et exp (number)