NestJS course Β· Module 4: Authentication
Passport.js - the access control system
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:
- Passport Module - registration: tells NestJS which sentries we employ at all.
- Strategy - the verification logic: one sentry checking one kind of document.
- Guard - activation: names which sentry we call at this particular gate.
- 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 throughsuper({...})and implementsvalidate(), validate()returns the user, who lands inrequest.user, and on rejection it throwsUnauthorizedException,- a guard is usually an empty class:
extends AuthGuard('local'),AuthGuard('jwt'),AuthGuard('google'), - you override
handleRequestwhen you want your own handling: check the error, check the user, return the object, - external strategies differ only in package and configuration; read
clientIDandclientSecretfromConfigService, 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');
96Spotted 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. What is Passport.js in the context of NestJS?
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