Protéger ses routes avec un Guard JWT dans NestJS

Protéger ses routes avec un Guard JWT dans NestJS

L'authentification est l'une des préoccupations centrales de toute API moderne. Dans une application NestJS, comment s'assurer qu'une route ne soit accessible qu'aux utilisateurs authentifiés ? La réponse passe par les Guards, ces composants élégants qui interceptent les requêtes avant qu'elles n'atteignent vos contrôleurs. Dans ce tutoriel, nous allons construire pas à pas un JwtAuthGuard robuste, capable de valider des tokens JWT, de gérer les routes publiques et de propager l'utilisateur authentifié dans toute la requête.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Une application NestJS (v10+) déjà initialisée
  • Les packages @nestjs/jwt, @nestjs/config et @nestjs/passport installés
  • Un module AuthService capable de générer des tokens JWT lors du login
  • Une entité User persistée via TypeORM (ou un équivalent)

Qu'est-ce qu'un Guard ? Le pattern CanActivate

Dans NestJS, un Guard est une classe annotée @Injectable() qui implémente l'interface CanActivate. Sa seule responsabilité : déterminer si une requête doit être traitée ou rejetée. C'est l'équivalent d'un videur à l'entrée d'un club — il vérifie l'identité avant de laisser entrer.

Le contrat est simple : la méthode canActivate(context: ExecutionContext) doit retourner un boolean (ou une Promise<boolean>). Si elle retourne true, la requête poursuit son chemin vers le contrôleur ; si elle retourne false ou lève une exception, NestJS renvoie une réponse d'erreur (typiquement 403 Forbidden ou 401 Unauthorized).

L'objet ExecutionContext est particulièrement puissant : il offre un accès uniforme au contexte d'exécution, que la requête vienne d'HTTP, de WebSocket ou de gRPC. Pour une API REST, on l'utilise via context.switchToHttp().getRequest().

Architecture d'un guard JWT dans NestJS
Image générée par Google Gemini

Construire le JwtAuthGuard

Voici l'implémentation complète de notre guard, telle qu'utilisée dans un projet NestJS de production :

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../../modules/auth/auth.service';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private jwtService: JwtService,
    private configService: ConfigService,
    private authService: AuthService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException('No token provided');
    }

    try {
      const user = await this.authService.validateToken(token);
      request.user = user;
      return true;
    } catch (error) {
      throw new UnauthorizedException('Invalid token');
    }
  }

  private extractTokenFromHeader(request: any): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Décortiquons les différentes étapes de cette logique.

1. Lire les métadonnées avec le Reflector

La première chose que fait le guard, c'est interroger le Reflector. Cet utilitaire permet d'accéder aux métadonnées posées via des décorateurs personnalisés. Ici, on cherche la clé IS_PUBLIC_KEY qui indique qu'une route est ouverte (login, register, healthcheck, etc.).

La méthode getAllAndOverride est cruciale : elle inspecte d'abord la méthode (context.getHandler()) puis la classe (context.getClass()). Si la métadonnée est trouvée à l'un des deux niveaux, on considère la route comme publique et le guard laisse passer immédiatement.

Le décorateur @Public() qui pose cette métadonnée est très simple :

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

Utilisation typique sur un contrôleur :

@Public()
@Post('login')
async login(@Body() dto: LoginDto) {
  return this.authService.login(dto);
}

2. Extraire le token du header Authorization

La méthode privée extractTokenFromHeader récupère le header Authorization et vérifie qu'il commence par Bearer. C'est la convention standard du protocole OAuth 2.0 :

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

L'opérateur de chaînage optionnel (?.) et le ?? [] évitent les erreurs si le header est absent. Si le format n'est pas Bearer xxx, la méthode retourne undefined, ce qui déclenchera une UnauthorizedException avec le message "No token provided".

3. Valider le token et attacher l'utilisateur

Une fois le token extrait, on délègue sa validation à l'AuthService. Voici la méthode correspondante :

async validateToken(token: string) {
  try {
    const payload = this.jwtService.verify(token, {
      secret: this.configService.get<string>('jwt.secret'),
    });
    const user = await this.userRepository.findOne({
      where: { id: payload.sub },
      select: ['id', 'email', 'firstName', 'lastName', 'role', 'isActive', 'tokenVersion'],
    });

    if (!user || !user.isActive) {
      throw new UnauthorizedException('User not found or inactive');
    }

    if (user.tokenVersion !== payload.tokenVersion) {
      throw new UnauthorizedException();
    }

    return user;
  } catch (error) {
    throw new UnauthorizedException('Invalid token');
  }
}

Trois vérifications successives sont effectuées :

  1. Signature et expiration : jwtService.verify() vérifie que la signature correspond au secret et que le token n'est pas expiré. Si l'une de ces conditions échoue, une exception est levée.
  2. Existence et activation de l'utilisateur : on charge l'utilisateur en base. S'il a été supprimé ou désactivé, le token devient invalide même s'il est cryptographiquement correct.
  3. Token version : un mécanisme de révocation. En incrémentant tokenVersion côté utilisateur (par exemple lors d'un changement de mot de passe ou d'une déconnexion forcée), tous les tokens émis précédemment deviennent invalides.

Quand tout est valide, la ligne request.user = user dans le guard attache l'utilisateur authentifié à la requête. On pourra le récupérer ensuite dans n'importe quel contrôleur via @Req() ou un décorateur @CurrentUser() personnalisé.

Configurer le module JWT

Pour que JwtService fonctionne, il faut configurer le module avec un secret et une durée d'expiration. On utilise registerAsync pour injecter ConfigService :

JwtModule.registerAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => {
    const secret = configService.get<string>('jwt.secret');
    if (!secret) {
      throw new Error('JWT_SECRET est obligatoire. L\'application ne peut pas démarrer sans.');
    }
    const expiresIn = configService.get<string>('jwt.expiresIn') || '30m';
    return {
      secret,
      signOptions: {
        expiresIn: expiresIn as any,
        algorithm: 'HS256' as const,
      },
    };
  },
}),

Notez le garde-fou explicite : si le secret n'est pas défini, l'application refuse de démarrer. C'est une bonne pratique pour éviter les déploiements en production avec une configuration vide qui produirait des tokens triviaux à forger.

Appliquer le guard globalement avec APP_GUARD

Vient maintenant la question de l'application du guard. Deux stratégies existent.

Approche locale : @UseGuards()

On place le décorateur sur un contrôleur ou une méthode spécifique :

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Req() req) {
  return req.user;
}

Cette approche est explicite mais répétitive. Sur une API où 95 % des routes sont protégées, on finit par décorer presque tout, ce qui devient un bruit visuel et un risque d'oubli.

Approche globale : APP_GUARD

L'inverse est plus sûr : on protège tout par défaut, et on libère explicitement avec @Public() les routes ouvertes. NestJS fournit le token APP_GUARD à cet effet :

import { Module, Global } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from '../../modules/auth/auth.module';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';

@Global()
@Module({
  imports: [AuthModule, /* JwtModule.registerAsync(...) */],
  providers: [
    JwtAuthGuard,
    RolesGuard,
    {
      provide: APP_GUARD,
      useExisting: JwtAuthGuard,
    },
    {
      provide: APP_GUARD,
      useExisting: RolesGuard,
    },
  ],
  exports: [JwtAuthGuard, RolesGuard, AuthModule],
})
export class GuardsModule {}

Quelques points importants :

  • @Global() rend le module disponible partout sans avoir à l'importer dans chaque feature module.
  • useExisting (au lieu de useClass) garantit qu'on partage la même instance du guard entre l'enregistrement global et les éventuelles utilisations locales. On évite ainsi d'avoir deux instances avec des dépendances dupliquées.
  • L'ordre d'enregistrement compte : JwtAuthGuard s'exécute avant RolesGuard, ce qui est logique puisqu'il faut d'abord authentifier avant de vérifier les rôles.

Gérer les tokens expirés ou invalides

Quand jwtService.verify() échoue, il lève l'une des erreurs suivantes :

  • TokenExpiredError : le token a dépassé son exp
  • JsonWebTokenError : signature invalide, format malformé, etc.
  • NotBeforeError : token utilisé avant sa date d'activation

Dans notre implémentation, toutes ces erreurs sont capturées dans le catch et converties en une seule UnauthorizedException. C'est un choix volontaire : on ne souhaite pas révéler au client la raison précise du rejet (un attaquant n'a pas besoin de savoir si son token est expiré ou simplement faux).

Si vous voulez offrir une meilleure UX au front (déclencher un refresh automatique sur expiration, par exemple), vous pouvez différencier les codes d'erreur dans le payload :

} catch (error) {
  if (error.name === 'TokenExpiredError') {
    throw new UnauthorizedException({ code: 'TOKEN_EXPIRED', message: 'Token expired' });
  }
  throw new UnauthorizedException({ code: 'INVALID_TOKEN', message: 'Invalid token' });
}

Conclusion

Vous disposez maintenant d'un système d'authentification JWT complet et idiomatique pour NestJS. Récapitulons les points clés :

  • Un Guard implémente CanActivate et statue sur l'accès à une route
  • Le Reflector permet de lire des métadonnées posées par des décorateurs personnalisés comme @Public()
  • L'extraction du token suit la convention Authorization: Bearer xxx
  • La validation combine vérification cryptographique, existence en base et système de révocation via tokenVersion
  • L'enregistrement global via APP_GUARD sécurise tout par défaut, ce qui est plus sûr qu'une approche opt-in

Pour aller plus loin, vous pouvez explorer les refresh tokens stockés en base de données, l'intégration de Passport.js pour ajouter d'autres stratégies (OAuth, SAML), ou encore la mise en place d'un guard RolesGuard qui s'enchaîne après le JwtAuthGuard pour gérer les autorisations fines basées sur les rôles utilisateurs.

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *