JWT strategy

Stateless authentication.

We can now proceed to the JWT part in order to conclude authentication. If you are still not familiar with the JWT (JSON Web Token), I recommend this read.

Let's go back to the AuthService and inject there the JwtService. It allows for signing the token. Then, add the login() method, responsible for returning the JWT from the user data.

login(user: RequestUser) {
  const payload = { sub: user.id };
  return this.jwtService.sign(payload);
}

The identification field in the payload is called sub to keep consistency with JWT standards.

However, with this current solution, we'll soon face the same situation of the user extracted from the request. That is, the lack of typing. Let's then already solve this. Create the file auth -> interfaces -> jwt-payload.interface.

export interface JwtPayload {
  readonly sub: number;
}

And apply the type of this interface to the payload.

const payload: JwtPayload = { sub: user.id };

Now, we should create environment variables for the JWT: SECRET and TTL (time-to-live). We'll perform that entire process related to creating, typing and using environment variables:

  • In the .env file, create the variables JWT_SECRET and JWT_TTL

It is recommended to use a long and unique secret, like a password from this site.

The TTL may be in ms format, like 7d (seven days).

  • Update the .env.example file accordingly

  • Require the existence of these variables in the ENV_VALIDATION_SCHEMA

  • Create a namespace in the file auth -> config -> jwt.config

export default registerAs('jwt', () => {
  const config = {
    secret: process.env.JWT_SECRET,
    signOptions: {
      expiresIn: process.env.JWT_TTL,
    },
  } as const satisfies JwtModuleOptions;
  return config;
});
  • In the AuthModule, add to the imports the JwtModule, configuring it with the jwtConfig

JwtModule.registerAsync(jwtConfig.asProvider()),

What is being done here, is to set the secret, which is needed to sign the token. We're also setting the token's expiration time. And of course, using the JwtService to create this signature.

In the AuthController, we can then return the JWT in the login() route.

return this.authService.login(user);

Let's just alter this route to return the JWT as a cookie, as it is considered a good practice. The options passed to the cookie below increase the security of this process.

@Post('login')
login(
  @User() user: RequestUser,
  @Res({ passthrough: true }) response: Response,
) {
  const token = this.authService.login(user);
  response.cookie('token', token, {
    secure: true,
    httpOnly: true,
    sameSite: true,
  });
}

The Response type is from express.

The passthrough option allows Nest to keep control the response in its pipeline.

In a real-world system, you may need to configure some CORS options to be able to successfully use cookies with those options.

Excellent, we're receiving a JWT when sending correct credentials. It is what will be used to verify whether or not we have permission to access a route. In the JwtModule configuration, we use the secret to generate the signature of the token that is returned. In the next step, we'll once again need the secret, but this time to verify if the token is valid when accessing protected routes. Let's then add one more item to the imports of the AuthModule.

ConfigModule.forFeature(jwtConfig),

Now, create the file strategies -> jwt.strategy with following contents. We're performing the same process we did earlier of injecting a configuration namespace, but now instead of doing this inside a dynamic module, we're doing it in a provider.

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    @Inject(jwtConfig.KEY)
    private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: jwtConfiguration.secret,
    });
  }

  validate(payload: JwtPayload) {
    const requestUser: RequestUser = { id: payload.sub };
    return requestUser;
  }
}

Strategy should be imported from passport-jwt.

Here, we're stating that, when a user tries to access a route that requires authentication, their JWT will be extracted as a Bearer Token, which is a recommended form to store it. And the secret to be used in the token validation is the same we've already defined. The validate() method has basically the same return as the one in the LocalStrategy, because Passport's strategies always append the return of this method to the request as the user field.

Now, we should just remember to add the JwtStrategy to the providers of the AuthModule.

However, there is a situation that may appear as a security vulnerability. Imagine that a user account has been deleted. If the token has not expired yet, it will still be possible to access routes with it. So, let's go back to the AuthService and create the method validateJwt(), which checks the existence of the user to whom a token is associated.

async validateJwt(payload: JwtPayload) {
  const user = await this.usersRepository.findOneBy({ id: payload.sub });
  if (!user) {
    throw new UnauthorizedException('Invalid token');
  }

  const requestUser: RequestUser = { id: payload.sub };
  return requestUser;
}

After that, back in the JwtStrategy, call validateJwt() inside the validate() method. Now, a token is valid only if its respective user still exists in the database.

validate(payload: JwtPayload) {
  return this.authService.validateJwt(payload);
}

In the next step, we'll use the AuthGuard of type jwt. So, let's already create another guard to represent it due to the reason already discussed of avoiding magic strings.

nest g gu auth/guards/jwt-auth
export class JwtAuthGuard extends AuthGuard('jwt') {}

Then, in the AuthController, let's create the route getProfile(), which returns the user that has the same id of the user appended to the request. We shall also protect it with our newly-created guard.

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@User() { id }: RequestUser) {
  return this.authService.getProfile(id);
}

And now create the namesake method in the AuthService.

getProfile(id: number) {
  return this.usersRepository.findOneBy({ id });
}

Great! Authentication is now functional. However, before proceeding to authorization, there's still one pending issue we should give attention to. Not all routes should require authentication, so we must learn how to have public routes.

Commit - Jwt strategy

Last updated