Cours NestJS · Module 4: Authentification

Les Refresh Tokens - renouveler les laissez-passer de la légion

9 min de lecture
Dans cette leçon9

L'access token des leçons précédentes vit 15 minutes. Un légionnaire éjecté du système tous les quarts d'heure aura vite fait de maudire l'architecte Vitruvius. Allonger le laissez-passer à une semaine, en revanche, revient à inviter les voleurs : un JWT reste valide jusqu'à sa date d'expiration, donc un token volé ouvre le fort pendant sept jours et ne peut pas être révoqué facilement. La solution : deux tokens aux rôles différents.

Que sont les Refresh Tokens ?

Imagine le système de laissez-passer d'un camp légionnaire. Le légionnaire reçoit deux documents :

  • Access Token (un court laissez-passer de garde) - il permet d'entrer dans le camp et expire au bout d'un quart d'heure environ
  • Refresh Token (le sceau du centurion) - il vit plusieurs jours et permet d'obtenir un nouveau laissez-passer sans repasser devant le tribunal

Pourquoi avons-nous besoin de deux tokens ?

  • Sécurité - si quelqu'un intercepte l'access token, il n'a accès que pour peu de temps
  • Confort - l'utilisateur n'a pas besoin de se reconnecter tous les quarts d'heure
  • Contrôle - nous pouvons invalider le refresh token et bloquer ainsi le renouvellement de l'accès

Le mécanisme des refresh tokens est le socle des systèmes d'authentification sécurisés dans les applications en production.

Générer une paire de tokens

Nous signons les deux tokens avec des secrets distincts, JWT_ACCESS_SECRET et JWT_REFRESH_SECRET, si bien qu'aucun ne peut être contrefait ni utilisé à la place de l'autre. Voici la connexion :

1// auth/auth.service.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5import { createHash } from 'node:crypto';
6
7@Injectable()
8export class AuthService {
9  constructor(
10    private jwtService: JwtService,
11    private configService: ConfigService,
12    private usersService: UsersService,
13  ) {}
14
15  async login(loginDto: LoginDto): Promise<TokenPair> {
16    const user = await this.usersService.validateCredentials(
17      loginDto.username,
18      loginDto.password,
19    );
20
21    if (!user) {
22      throw new UnauthorizedException('Identifiants de connexion invalides, légionnaire !');
23    }
24
25    // Générer une paire de tokens
26    const tokens = await this.generateTokenPair(user);
27
28    // Enregistrer l'empreinte SHA-256 du refresh token, pas le token lui-même
29    const hashedRefreshToken = this.hashToken(tokens.refreshToken);
30    await this.usersService.updateRefreshToken(user.id, hashedRefreshToken);
31
32    return tokens;
33  }

validateCredentials() vérifie le mot de passe avec bcrypt.compare(), vu dans la leçon sur le hachage. Seule l'empreinte du refresh token arrive dans la base - l'access token n'est stocké nulle part, car il expire de toute façon au bout d'un quart d'heure. Tu découvriras le type UserDocument dans un instant, avec le schéma.

Une méthode séparée crée la paire de tokens :

1  async generateTokenPair(user: UserDocument): Promise<TokenPair> {
2    const payload = {
3      sub: user.id,
4      username: user.username,
5      role: user.role,
6      cohort: user.cohortName,
7    };
8
9    const [accessToken, refreshToken] = await Promise.all([
10      this.jwtService.signAsync(payload, {
11        secret: this.configService.get('JWT_ACCESS_SECRET'),
12        expiresIn: '15m', // Laissez-passer de garde - 15 minutes
13      }),
14      this.jwtService.signAsync(payload, {
15        secret: this.configService.get('JWT_REFRESH_SECRET'),
16        expiresIn: '7d', // Sceau du centurion - durée de vie plus longue
17      }),
18    ]);
19
20    return { accessToken, refreshToken };
21  }
22
23  // Empreinte du refresh token : SHA-256, car bcrypt ne lit que 72 octets
24  private hashToken(token: string): string {
25    return createHash('sha256').update(token).digest('hex');
26  }
27}

signAsync() reçoit un secret et un expiresIn pour chaque token séparément, et Promise.all() signe les deux en parallèle. Le payload est le même ; seuls le secret et la durée de vie diffèrent. Remarque hashToken() : il utilise volontairement SHA-256, et non bcrypt. bcrypt ne lit que 72 octets, et deux JWT du même légionnaire ont un début identique (l'en-tête et les premiers champs du payload), donc bcrypt.compare(ancienToken, hachageDuNouveau) renverrait true. Le token est long et aléatoire, une empreinte rapide suffit donc largement.

Un point hors de ce fichier : JwtStrategy doit vérifier les access tokens avec le même secret que celui qui les signe. Si tu introduis JWT_ACCESS_SECRET, modifie aussi son secretOrKey.

L'endpoint de renouvellement du token

Le contrôleur expose trois routes : la connexion, le renouvellement et la déconnexion :

1// auth/auth.controller.ts
2@Controller('auth')
3export class AuthController {
4  constructor(private authService: AuthService) {}
5
6  @Post('login')
7  async login(@Body() loginDto: LoginDto) {
8    return this.authService.login(loginDto);
9  }
10
11  @Post('refresh')
12  async refreshTokens(@Body('refreshToken') refreshToken: string) {
13    return this.authService.refreshTokens(refreshToken);
14  }
15
16  @Post('logout')
17  @UseGuards(JwtAuthGuard)
18  async logout(@Req() req) {
19    // Invalider le refresh token à la déconnexion
20    await this.authService.logout(req.user.userId);
21    return { message: 'Légionnaire déconnecté avec succès' };
22  }
23}

/auth/refresh n'a pas de JwtAuthGuard, car on l'appelle justement quand l'access token a expiré - c'est le refresh token lui-même qui sert d'identifiant. La déconnexion est protégée, et req.user.userId vient de JwtStrategy, dont la méthode validate() renvoie { userId, username, role }.

La logique de renouvellement dans le service

La méthode refreshTokens() commence par vérifier si le sceau est authentique et à qui appartient le document :

1// auth/auth.service.ts (suite)
2async refreshTokens(refreshToken: string): Promise<TokenPair> {
3  // 1. Vérifier le refresh token
4  let payload;
5  try {
6    payload = await this.jwtService.verifyAsync(refreshToken, {
7      secret: this.configService.get('JWT_REFRESH_SECRET'),
8    });
9  } catch (error) {
10    throw new UnauthorizedException('Le sceau du centurion a expiré - reconnecte-toi !');
11  }
12
13  // 2. Trouver l'utilisateur et vérifier le refresh token stocké
14  const user = await this.usersService.findById(payload.sub);
15
16  if (!user || !user.hashedRefreshToken) {
17    throw new UnauthorizedException('Accès refusé !');
18  }

verifyAsync() avec le secret de rafraîchissement vérifie la signature et la date d'expiration. L'absence d'empreinte stockée signifie que le légionnaire s'est déconnecté ou que sa session a été révoquée.

Ensuite, nous comparons le token avec la base et délivrons une nouvelle paire :

1  // 3. Comparer l'empreinte du token avec celle stockée
2  const isTokenValid = this.hashToken(refreshToken) === user.hashedRefreshToken;
3
4  if (!isTokenValid) {
5    // Vol de token potentiel - invalider toutes les sessions
6    await this.usersService.updateRefreshToken(user.id, null);
7    throw new UnauthorizedException('Token invalidé - activité suspecte détectée !');
8  }
9
10  // 4. Générer une nouvelle paire de tokens (Token Rotation)
11  const newTokens = await this.generateTokenPair(user);
12
13  // 5. Enregistrer le nouveau refresh token
14  const hashedNewRefreshToken = this.hashToken(newTokens.refreshToken);
15  await this.usersService.updateRefreshToken(user.id, hashedNewRefreshToken);
16
17  return newTokens;
18}
19
20async logout(userId: string): Promise<void> {
21  // Invalider le refresh token
22  await this.usersService.updateRefreshToken(userId, null);
23}

Si l'empreinte ne correspond pas, quelqu'un a présenté un token correctement signé mais périmé - c'est un signe de vol, alors nous effaçons l'empreinte stockée et tout le monde doit se reconnecter. logout() fait la même chose à la demande de l'utilisateur.

La Token Rotation - rotation des tokens

Une technique de sécurité essentielle : à chaque renouvellement, nous générons un nouveau refresh token et invalidons l'ancien. Si quelqu'un vole un refresh token et tente de s'en servir après la rotation, nous le détecterons :

1// Schéma de protection contre le vol de token
2// 1. Le légionnaire se connecte -> reçoit AT1 + RT1
3// 2. AT1 expire -> envoie RT1 -> reçoit AT2 + RT2 (RT1 invalidé)
4// 3. Le voleur tente d'utiliser RT1 -> REFUSÉ (token déjà pivoté)
5// 4. Le système détecte l'usage d'un ancien token -> invalide TOUTES les sessions de l'utilisateur

L'ancien RT1 a toujours une signature et une date de validité correctes. Seule la comparaison avec l'empreinte en base le rejette - et c'est pour cela que nous conservons cette empreinte.

Le schéma utilisateur avec un refresh token

L'empreinte va dans le champ hashedRefreshToken du schéma Mongoose :

1// users/user.schema.ts (Mongoose)
2import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
3import { HydratedDocument } from 'mongoose';
4
5@Schema({ timestamps: true })
6export class User {
7  @Prop({ required: true, unique: true })
8  username: string;
9
10  @Prop({ required: true })
11  password: string;
12
13  @Prop()
14  email: string;
15
16  @Prop({ default: 'miles' })
17  role: string;
18
19  @Prop({ type: String, default: null })
20  hashedRefreshToken: string | null;
21
22  @Prop()
23  cohortName: string;
24}
25
26export type UserDocument = HydratedDocument<User>;
27export const UserSchema = SchemaFactory.createForClass(User);

L'option nullable vient de TypeORM, et Mongoose l'ignore tout simplement. Ici, null est autorisé par défaut, et avec le type string | null il faut indiquer explicitement type: String. HydratedDocument<User> est le modèle de la documentation actuelle de NestJS : la classe décrit les champs, et le type du document ajoute id et les méthodes de Mongoose.

Le Refresh Token Guard

Au lieu de vérifier le token à la main, tu peux confier ce travail à Passport. Une stratégie nommée 'jwt-refresh' lit le token dans un champ du body :

1// auth/guards/refresh-token.guard.ts
2import { Injectable } from '@nestjs/common';
3import { AuthGuard } from '@nestjs/passport';
4
5@Injectable()
6export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {}
7
8// auth/strategies/refresh-token.strategy.ts
9import { Injectable } from '@nestjs/common';
10import { PassportStrategy } from '@nestjs/passport';
11import { ExtractJwt, Strategy } from 'passport-jwt';
12import { ConfigService } from '@nestjs/config';
13import { Request } from 'express';
14
15@Injectable()
16export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
17  constructor(private configService: ConfigService) {
18    super({
19      jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
20      secretOrKey: configService.getOrThrow('JWT_REFRESH_SECRET'),
21      passReqToCallback: true,
22    });
23  }
24
25  validate(req: Request, payload: any) {
26    const refreshToken = req.body.refreshToken;
27    return { ...payload, refreshToken };
28  }
29}

passReqToCallback: true transmet toute la requête à validate(), si bien que le token brut arrive dans req.user.refreshToken. La stratégie ne vérifie que la signature et la date, c'est pourquoi la comparaison avec l'empreinte en base reste de toute façon dans le service. Côté client, garde le refresh token dans un cookie httpOnly, inaccessible au JavaScript - c'est ce que je te recommande.

Exercice pratique

Implémente un système complet de refresh tokens pour la légion, étape par étape :

1// Ton code ici
2// 1. Configurer deux secrets JWT (access et refresh) dans ConfigService
3// 2. Implémenter l'endpoint POST /auth/refresh avec validation
4// 3. Ajouter la Token Rotation à chaque renouvellement
5// 4. Implémenter la détection de la réutilisation d'anciens tokens
6// 5. Ajouter le nettoyage des tokens expirés (tâche cron)

Pour nettoyer les anciennes entrées, @nestjs/schedule et son décorateur @Cron() te seront utiles.

Résumé

Te voilà nommé gardien des portes de l'Empire ! Désormais, tu sais :

  • Générer des paires de tokens - access token (court) + refresh token (long)
  • Implémenter la Token Rotation - un nouveau refresh token à chaque renouvellement
  • Détecter le vol de tokens - invalider les sessions quand un ancien token est réutilisé
  • Stocker en sécurité les refresh tokens dans la base sous forme d'empreinte SHA-256
  • Implémenter la déconnexion - invalider le refresh token au logout
  • Créer des Passport Strategies pour les refresh tokens

Dans la prochaine leçon, nous aborderons OAuth et la connexion avec Google.

Souviens-toi : le laissez-passer de garde doit être court et le sceau du centurion à usage unique, car chaque rotation ferme la route à celui qui a volé le précédent.

Code de cette leçon : src/auth/refresh-tokens.ts
1// Refresh Tokens - renouveler les laissez-passer de la légion
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5
6interface TokenPair {
7  accessToken: string;
8  refreshToken: string;
9}
10
11interface JwtPayload {
12  sub: string;
13  username: string;
14  role: string;
15}
16
17@Injectable()
18export class RefreshTokenService {
19  constructor(
20    private jwtService: JwtService,
21    private configService: ConfigService,
22  ) {}
23
24  // TODO: Implémente la génération d'une paire de tokens
25  // accessToken - courte durée de vie (15m)
26  // refreshToken - longue durée de vie (7d)
27  async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
28    // TODO: Génère accessToken
29    const accessToken = this.jwtService.sign(payload, {
30      secret: '', // TODO: récupère JWT_ACCESS_SECRET depuis configService
31      expiresIn: '', // TODO: définis à '15m'
32    });
33
34    // TODO: Génère refreshToken avec un secret différent
35    const refreshToken = this.jwtService.sign(payload, {
36      secret: '', // TODO: récupère JWT_REFRESH_SECRET depuis configService
37      expiresIn: '', // TODO: définis à '7d'
38    });
39
40    return { accessToken, refreshToken };
41  }
42
43  // TODO: Implémente le renouvellement des tokens
44  async refreshTokens(oldRefreshToken: string): Promise<TokenPair> {
45    try {
46      // TODO: Vérifie l'ancien refreshToken
47      const payload = this.jwtService.verify(oldRefreshToken, {
48        secret: '', // TODO: même secret que lors de la génération refresh
49      });
50
51      // TODO: Génère une nouvelle paire de tokens
52      const newPayload: JwtPayload = {
53        sub: payload.sub,
54        username: payload.username,
55        role: payload.role,
56      };
57
58      return this.generateTokenPair(newPayload);
59    } catch (error) {
60      throw new UnauthorizedException('Le refresh token a expiré ou est invalide !');
61    }
62  }
63}
64
65console.log('Access Token : courte durée de vie (15 minutes)');
66console.log('Refresh Token : longue durée de vie (7 jours)');
67console.log('La rotation des tokens renforce la sécurité');
68

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. Quelle est la différence typique de durée de vie entre un Access Token et un Refresh Token ?

Tâches pratiques dans le jeu

  • Tri vertical

    Placez les étapes du renouvellement d'un token JWT :