We use cookies to enhance your experience on the site
CodeWorlds

Passport.js - the access control system

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, @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.

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.

Go to CodeWorlds