NestJS course Β· Module 4: Authentication

Passport.js - the access control system

7 min read
In this lesson6

You already know how to issue a JWT pass and check a password. But in a real Empire all sorts of visitors approach the gates: one has a password, another a sealed pass, a third a letter of recommendation from an ally in Gaul, a fourth a key to the trade gate. Handling each of them separately means duplicating the same logic four times - and every way in is a fresh chance to get it wrong.

The Romans put a guardhouse at the gate with one set of regulations and many sentries: each sentry knows one kind of document, but they all report in the same way. In NestJS that guardhouse is Passport.js - a library where every login method is a separate strategy, and the ecosystem offers over 500 ready-made ones.

The guardhouse's three layers

Before we write a line of code, let's establish what this guardhouse is made of - it is the skeleton of the whole lesson and it returns with every strategy:

  1. Passport Module - registration: tells NestJS which sentries we employ at all.
  2. Strategy - the verification logic: one sentry checking one kind of document.
  3. Guard - activation: names which sentry we call at this particular gate.
  4. The @UseGuards() decorator - posting the guard at an endpoint's entrance.

Remember the order from the bottom up: the strategy knows how to check, the guard knows whom to call, the decorator knows where to post them.

The local strategy - the password sentry

Let's start with the simplest sentry: he checks a login and a password.

1@Injectable()
2export class LocalStrategy extends PassportStrategy(Strategy) {
3  constructor(private authService: AuthService) {
4    super({
5      usernameField: 'username',
6      passwordField: 'password',
7    });
8  }
9
10  async validate(username: string, password: string): Promise<any> {
11    const user = await this.authService.validateUser(username, password);
12
13    if (!user) {
14      throw new UnauthorizedException();
15    }
16
17    return user;
18  }
19}

Let's walk through this class, because each of its elements will repeat in the strategies that follow. PassportStrategy(Strategy) is a base class built from the imported Strategy - here from the passport-local package. Change the package and you change the kind of sentry, while the rest of the skeleton stays.

The super() call in the constructor configures the strategy. usernameField and passwordField say which request fields to take the data from - and this is a common snagging point, because if your form sends email instead of username, this is exactly where you declare it.

The heart is validate(). Passport calls it itself, handing over the extracted fields, and your job is to answer: who is this. Note this method's contract - it is the most important sentence in this lesson. The returned object lands in request.user and becomes available in the controller. When the document is false, you do not return null or false - you throw UnauthorizedException, and NestJS turns it into a 401 response.

The guard - calling a sentry by name

A strategy will not act on its own. It has to be activated, and that is what a guard does:

1@Injectable()
2export class LocalAuthGuard extends AuthGuard('local') {}

Yes, that is the whole class body - empty. AuthGuard('local') builds a ready-made guard which finds the registered strategy by name and runs its validate(). The name 'local' is not arbitrary: it is the default identifier of the strategy from the passport-local package, just as 'jwt' belongs to passport-jwt and 'google' to the Google strategy.

The guard for the JWT passes you met in the previous lesson looks the same:

1@Injectable()
2export class JwtAuthGuard extends AuthGuard('jwt') {}

Why write a class at all, when you could put @UseGuards(AuthGuard('local')) directly? For two reasons: the name LocalAuthGuard reads better in a controller, and when you want to add your own behaviour, you have somewhere to put it. That is exactly what the handleRequest method is for, and you can override it:

1@Injectable()
2export class JwtAuthGuard extends AuthGuard('jwt') {
3  handleRequest(err: any, user: any) {
4    if (err) {
5      throw err;
6    }
7
8    if (!user) {
9      throw new UnauthorizedException('Pass invalid or expired');
10    }
11
12    return user;
13  }
14}

The order here is logical and worth remembering: first you check the error, then the presence of a user, and only at the end you return the object - and what you return lands in request.user. We override this method mainly to give a readable message instead of a bare 401.

An external strategy - the letter from Gaul

Since the skeleton does not change, adding Google login comes down to swapping the package and the configuration. The sentry is new, the regulations are the same.

1@Injectable()
2export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
3  constructor(private configService: ConfigService) {
4    super({
5      clientID: configService.get('GOOGLE_CLIENT_ID'),
6      clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
7      callbackURL: '/auth/google/callback',
8      scope: ['email', 'profile'],
9    });
10  }
11}

Here Strategy comes from the passport-google-oauth20 package. clientID and clientSecret are your application's credentials issued by Google - which is why we read them through ConfigService from environment variables instead of writing them into the code. A secret in a repository is somebody else's secret; I recommend treating that as a rule without exceptions.

callbackURL is the address Google will send the user back to after login - your application must have an endpoint there. scope declares which data you are asking for.

Note the second argument in PassportStrategy(Strategy, 'google'): it is an explicitly given name, by which the AuthGuard('google') guard will find this strategy. With the local strategy we could omit it, because the default name was enough.

Posting the guard at the gate

The last step is naming which endpoints the guard watches over:

1@Controller('auth')
2export class AuthController {
3  @UseGuards(LocalAuthGuard)
4  @Post('login')
5  async login(@Request() req) {
6    return this.authService.generateToken(req.user);
7  }
8
9  @UseGuards(JwtAuthGuard)
10  @Get('profile')
11  getProfile(@Request() req) {
12    return req.user;
13  }
14}

Here the whole chain closes. The guard ran the strategy, the strategy executed validate(), the returned object landed in request.user - and only now can the controller method reach for it. If verification failed, the method does not run at all: the guard stops the request before it reaches the controller.

Summary

The guardhouse stands, and you can employ any sentry in it:

  • Passport.js is an authentication framework in which every login method is a separate strategy - the ecosystem provides over 500 ready-made ones,
  • the layers stack from the bottom: strategy (how to check), guard (whom to call), @UseGuards() (where to post them),
  • a strategy extends PassportStrategy(Strategy), is configured through super({...}) and implements validate(),
  • validate() returns the user, who lands in request.user, and on rejection it throws UnauthorizedException,
  • a guard is usually an empty class: extends AuthGuard('local'), AuthGuard('jwt'), AuthGuard('google'),
  • you override handleRequest when you want your own handling: check the error, check the user, return the object,
  • external strategies differ only in package and configuration; read clientID and clientSecret from ConfigService, never from code,
  • the second argument of PassportStrategy(Strategy, 'name') gives the strategy the name its guard will look it up by.

In the next lesson we will go one level deeper - to roles and permissions, the question of what a visitor we have already admitted is allowed to do. For now remember: the strategy knows how to check a document, the guard knows which sentry to call, and request.user is the report left behind by a successful check.

Code for this lesson: src/auth/passport-strategies.ts
1// Passport.js - Universal Access Control System
2// Passport strategies for various login methods
3import { Injectable, UnauthorizedException } from '@nestjs/common';
4import { PassportStrategy } from '@nestjs/passport';
5import { Strategy as LocalStrategy } from 'passport-local';
6import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
7
8// ===========================================
9// 1. Local Strategy - login with a password
10// ===========================================
11
12@Injectable()
13export class RomanLocalStrategy extends PassportStrategy(LocalStrategy) {
14  constructor() {
15    super({
16      usernameField: 'username', // Field with username
17      passwordField: 'password', // Field with the password
18    });
19  }
20
21  // Method called at login
22  async validate(username: string, password: string) {
23    // Here call AuthService.validateUser()
24    console.log('Local Strategy: checking', username);
25
26    // Validation simulation
27    if (username === 'caesar' && password === 'spqr') {
28      return { id: '1', username: 'caesar', rank: 'consul' };
29    }
30
31    throw new UnauthorizedException('Incorrect login data!');
32  }
33}
34
35// ===========================================
36// 2. JWT Strategy - token verification
37// ===========================================
38
39@Injectable()
40export class RomanJwtStrategy extends PassportStrategy(JwtStrategy) {
41  constructor() {
42    super({
43      // Where to extract the token from
44      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
45      // Whether to ignore expiration
46      ignoreExpiration: false,
47      // Secret for verifying the signature
48      secretOrKey: 'spqr-secret-key',
49    });
50  }
51
52  // Method called after token decoding
53  async validate(payload: any) {
54    console.log('JWT Strategy: token decoded', payload);
55
56    // The payload from the token goes into req.user
57    return {
58      id: payload.sub,
59      username: payload.username,
60      rank: payload.rank,
61    };
62  }
63}
64
65// ===========================================
66// 3. Using the strategy in a controller
67// ===========================================
68
69import { Controller, Post, Get, UseGuards, Request } from '@nestjs/common';
70import { AuthGuard } from '@nestjs/passport';
71
72@Controller('auth')
73export class AuthController {
74  // Login - uses LocalStrategy
75  @Post('login')
76  @UseGuards(AuthGuard('local'))
77  async login(@Request() req) {
78    console.log('Logged in:', req.user);
79    return { message: 'Welcome to the Empire!', user: req.user };
80  }
81
82  // Protected endpoint - uses JwtStrategy
83  @Get('profile')
84  @UseGuards(AuthGuard('jwt'))
85  getProfile(@Request() req) {
86    console.log('Legionary profile:', req.user);
87    return { user: req.user };
88  }
89}
90
91console.log('=== Passport Strategies ===');
92console.log('LocalStrategy: username + password -> validate()');
93console.log('JwtStrategy: Bearer token -> validate(payload)');
94console.log('AuthGuard("local") -> uses LocalStrategy');
95console.log('AuthGuard("jwt") -> uses JwtStrategy');
96

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is Passport.js in the context of NestJS?

  2. 2. What does the validate() method in LocalStrategy do?

These are 2 of 12 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete the LocalStrategy class that extends PassportStrategy(Strategy) from passport-local and implements validate(username, password) throwing UnauthorizedException when the user does not exist

  • Vertical ordering

    Arrange the super() configuration options in LocalStrategy from left to right

  • Click in order

    Arrange the elements of the LocalAuthGuard definition in the correct order

  • Code editor

    Create a JwtAuthGuard class with the @Injectable() decorator that extends AuthGuard('jwt')

  • Vertical ordering

    Order the authentication layers in NestJS from lowest to highest

  • Code editor

    Complete the GoogleStrategy with clientID and clientSecret options fetched from ConfigService and callbackURL set to '/auth/google/callback'

  • Click in order

    Arrange the logic of the handleRequest method in AuthGuard from first to last step

  • Vertical ordering

    Order the steps of the OAuth 2.0 process (e.g., logging in via Google)

  • Code editor

    Complete the AuthController with a @Post('login') endpoint protected by @UseGuards(LocalAuthGuard) that returns a JWT token

  • Horizontal ordering

    Arrange the syntax for using multiple guards on an endpoint

  • Code editor

    Complete the generateRefreshToken method in AuthService that creates a JWT with expiresIn: '7d' and contains a payload with userId and tokenType: 'refresh'

  • Click in order

    Arrange the steps of the JWT token refresh process

  • Code editor

    Complete the controller with a @Get('profile') endpoint protected by @UseGuards(JwtAuthGuard) that returns the logged-in user's data from @Req() req

  • Vertical ordering

    Arrange the syntax for retrieving the user from the request in a protected endpoint

  • Click in order

    Arrange the elements of the jwtService.sign() call in the correct order

  • Code editor

    Complete the login(user) method in AuthService that creates a payload with sub: user.id and username: user.username, then returns { access_token: this.jwtService.sign(payload) }

  • Vertical ordering

    Order the AuthModule configuration elements from imports to exports

  • Code editor

    Complete the validate method in AuthService that checks email and password, and throws throw new UnauthorizedException('Invalid login credentials') when the user is not found

  • Horizontal ordering

    Arrange the syntax for importing AuthGuard from @nestjs/passport

  • Click in order

    Arrange the order of execution of layers in an HTTP request with authentication

  • Code editor

    Complete the AuthModule importing PassportModule, JwtModule.registerAsync(jwtConfig), with providers: [AuthService, JwtStrategy, LocalStrategy] and exports: [AuthService]

  • Horizontal ordering

    Arrange the syntax of the validate method in JwtStrategy from async to returning the object

  • Vertical ordering

    Order the JWT token lifecycle from creation to expiration

Useful articles