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.
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:
@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.
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.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.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, @name.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.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.The guardhouse stands, and you can employ any sentry in it:
@UseGuards() (where to post them),PassportStrategy(Strategy), is configured through super({...}) and implements validate(),validate() returns the user, who lands in request.user, and on rejection it throws UnauthorizedException,extends AuthGuard('local'), AuthGuard('jwt'), AuthGuard('google'),handleRequest when you want your own handling: check the error, check the user, return the object,clientID and clientSecret from ConfigService, never from code,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.