Authentication in a Nest.js Application with Neo4j
This post is the second in a series of blog posts that accompany the Twitch stream on the Neo4j Twitch channel where I build an application on top of Neo4j with NestJS.
If you haven’t already done so, check out the first article here or watch the videos back on the Neo4j Youtube Channel.
Authentication is a key part of any subscription or SaaS site. For every request, we should be able to verify who the user is and whether they have the correct subscription to do what they are trying to do. As such, API calls to view or stream any content via the API will require valid user credentials. To enforce this, we will use a combination of Nest.js Guards and JWT tokens.
To provide Authentication we will need REST endpoints to allow users to create an account and then to log in with those credentials. We will be using a combination of Email address and Password as the method for authentication so that the user doesn’t need to supply an email address and a username.
JWTs
JWT’s (pronounced JOT) — short for JSON Web Tokens — are compact tokens — backed by an open standard (RFC 7519) — that are designed to provide a secure way of transmitting information between parties — or in our case sharing user authentication information between the API and front end.
On the surface, a JWT token could look a little like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
This is a base64 encoded string which contains three pieces of information, split by a dot.
The Header
When decoded, the header will contain information about the type of token and the algorithm that has been used to sign the token.
{
"alg": "HS256",
"typ": "JWT"
}
The Payload
The token’s payload is a base64 encoded JSON object that contains certain claims about the User, for example information about who they are. Although there are some reserved claims (for example the issuer or iss
, subject or sub
and expiry or exp
), any information can be added to the payload. It is worth remembering that the payload of a JWT token can be easily decoded, so this shouldn't contain any sensitive information about the user. In our case, we will add some basic information about the User into the token so that the UI can be customised without making unnecessary requests to the API.
{
"iss": "our-api",
"sub": "user-1234",
"exp": 1595245369
}
In the example above, the token has been issued by our-api
for user-1234
with an expiry time of 1595245369
- seconds since epoch.
The Signature
The signature ensures that the information held in the payload has not been tampered with. After base64 encoding the payload, a signature is generated using a secret passphrase or PEM key. When the key is read, the signature is regenerated and checked against the value provided. If the two signatures do not match then the token is rejected. This ensures that as long as no one knows the key used to sign the original token, the contents will always be valid.
Adding Authentication to Nest
Nest.js comes with a built in module for Passport, a widely used library for authenticating users. We will be using it to validate each request, generate JWT tokens during the login process, and verify those tokens in subsequent requests. But before we get into that, we’ll need to create an Authentication Service to handle the business logic.
To follow the conventions set out by Nest, we should create a new module which we can register in the main application. This module should provide access to a service which will handle the authentication and a controller to accept the HTTP requests.
nest g mo auth
nest g s auth
nest g co auth
The Auth Service shouldn’t be tasked with querying the user information, so we should also create a User Module and Service to handle communication with the database. This has the added benefit of allowing us to share this functionality across the application rather than duplicating the code — meaning we adhere to the principles of DRY (Don’t Repeat Yourself). If we want to create or find a User, we can just inject the User Service into a class.
nest g mo user
nest g s user
Ensuring Emails are Unique
Because users will be authenticating with their email address, we need to make sure that an email address is unique. We could add a check as part of the validation stage to check if the username exists but this would double the number of requests required and also add network latency to the request time.
Instead, we can add a constraint to the database to ensure that the email
property is unique for any node with a :User
label. To do this, we can run a CREATE CONSTRAINT
statement in Neo4j:
CREATE CONSTRAINT ON (u:User) ASSERT u.email IS UNIQUE
Now if the user passes all other validation required by the application layer, the database will throw a ClientException
which we can catch in the API.
Registering as a User — POST /auth/register
Before we can authenticate a User, it has to exist in the database. So the first thing to do would be to create an endpoint to allow a User to create an account.
We’ll need to create a DTO (Data Transfer Object) to represent the payload that the function should receive. By using the @Body()
decorator, Nest will coerce the request body into the class that has been type-hinted by the route handler.
The root folder of the module is getting a little crowded already, so I personally prefer to create a dto/
folder to hold the DTO classes.
mkdir src/user/dto
touch src/user/dto/create-user.dto.ts
An added benefit of DTO’s is that if we add decorators to the properties, Nest will automatically validate the request and reject any requests that don’t meet the requirements that have been set out. @nest/core
comes with a ValidationPipe that we'll need to register as a Global Pipe in the bootstrap
function in main.ts
.
Pipes are injectable classes that either transform or validate inputs into the application. — For example, the ParseIntPipe
can be used to transform a URL parameter into a number
type.
The ValidationPipe uses the class-validator
and class-transformer
packages so we'll have to install those:
npm i --save class-validator class-transformer
Then register the ValidationPipe as a Global Pipe in main.ts
to ensure that it is used for all HTTP requests.
// main.ts
async function bootstrap() {
const app = await NestFactory.create(ApplicationModule); // Use Global Pipes
app.useGlobalPipes(new ValidationPipe()); await app.listen(3000);
}
bootstrap();
For a User to register, we’ll need an email address and password. Then to personalise their service a name, and date of birth will be useful for controlling. The user’s date of birth will be also required to ensure that a User can access the content they’ve requested.
We’ll add some validation on the date to ensure that the user is at least 13 years old in accordance with UK law. To do this, we can use the moment
package - npm i moment
and subtract 13 years.
The create-user.dto.ts
should look something like this:
import { Type } from 'class-transformer'
import { IsEmail, IsNotEmpty, IsDate, MaxDate } from 'class-validator';export class CreateUserDto {
@IsEmail()
@IsNotEmpty()
email: string; @IsNotEmpty()
password: string; @IsNotEmpty()
@IsDate()
@MaxDate(require('moment')().subtract(13, 'y').toDate())
@Type(() => Date)
dateOfBirth: Date; firstName: string;
lastName: string;
}
The @Type
decorator from 'class-transformer' will take the value and transform it into a Date
.
Note: At the time of writing, the moment
library wasn't playing well with typescript imports. After a quick google, I found that adding "esModuleInterop": true,
to compilerOptions
in tsconfig.json
seemed to do the trick.
Next, the route in auth.controller.ts
. Any routes in the auth controller are prefixed with auth/
as defined in the @Controller
decorator, so to create a REST endpoint for a POST request to /auth/register, we can create the @Post
decorator.
// auth.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { CreateUserDto } from '../user/dto/create-user.dto';@Controller('auth')
export class AuthController { constructor(private readonly userService: UserService) {} @Post('register')
async postRegister(@Body() createUserDto: CreateUserDto) {
// TODO: Create User
return createUserDto
}}
Nest can’t resolve dependencies of ???
If you’re running the code in dev mode, you’ll now see something along the lines of:
[ExceptionHandler] Nest can't resolve dependencies of the AuthController (?). Please make sure that the argument UserService at index [0] is available in the AuthModule context.Potential solutions:
- If UserService is a provider, is it part of the current AuthModule?
- If UserService is exported from a separate @Module, is that module imported within AuthModule?
@Module({
imports: [ /* the Module containing UserService */ ]
})
Ahh, dependency injection, our old friend. Nest doesn’t recognise UserService
, so to fix this error we'll need to:
- Add
UserModule
as an import to theAuthModule
so that anything exported from Auth module can be injected into classes in the User module
// auth.module.ts
import { UserModule } from '../user/user.module';@Module({
imports: [UserModule],
providers: [AuthService],
controllers: [AuthController]
})
export class AuthModule {}
- Add an
exports
array containingUserService
toUserModule
so that Nest’s IoC container recognises the module.
import { UserService } from './user.service';
@Module({
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
There’s nothing like a live coding disaster to cement a solution into your head.
Testing the Endpoint
To verify this is working, we could write a cURL request or open up Postman. But instead, let’s look at writing a test. So far we’ve not looked at tests, but you may have noticed that when we ran the nest g co auth
command, an auth.controller.spec.ts
file was also generated.
If we run the following command, the jest
rest runner will run in watch mode. This is really useful for development because the test runner will watch for changes to files and automatically re-run tests. So if a change in one file breaks another, you'll know instantly.
npm run test:watch
Because we’ve not looked at this yet there will be a load of failures but let’s not worry about them too much for now. Instead, the last few lines gives instructions on how to filter the tests that are being run.
Test Suites: 4 failed, 1 passed, 5 total
Tests: 2 failed, 1 passed, 3 total
Snapshots: 0 total
Time: 1.474 s, estimated 2 s
Ran all test suites related to changed files.Watch Usage
› Press a to run all tests.
› Press f to run only failed tests.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press q to quit watch mode.
› Press Enter to trigger a test run.
I usually like to run only the tests that have failed (option f
), but right now we're only interested in the Auth controller.
We can type p
to filter the tests by filename, then type auth.controller
to only run the tests in auth.controller.spec.ts
.
We really should add some tests to auth.controller.spec.ts
but for now, end-to-end tests are more appealing because they will test whether the guard is working or not. Where the existing tests are great for testing smaller 'units' of cod, an end-to-end test will ensure that the entire stack is working correctly.
The nest new
command generates an app.e2e-spec.ts
file which creates a test module that automatically includes everything that has been imported into the AppModule
, so it will already have our AuthController
registered.
If we run the end-to-end tests using the npm run test:e2e
it'll currently fail because the default test expects GET /
to return Hello World but we've already updated it. It's kind of an irrelevant test so for now we can just comment it out.
Jest’s describe
function allows you to group tests together to make the results more readable. We'll be testing the Auth functionality, we can create a group called 'Auth', then inside call the function again to group the tests for the register endpoint.
describe('Auth', () => {
describe('POST /auth/register', () => {
// Tests go here:
})
})
Then, the it
function defines the test. I always like to start my tests with should
so it kind of reads like a sentence but go with whatever you see fit.
The first thing it should do is validate the response based on the decorators in the DTO. Nest will return a HTTP 400 Bad Request
status code, so we can verify that
describe('Auth', () => {
describe('POST /auth/register', () => {
it('should validate request', () => {
return request(app.getHttpServer())
.post('/auth/register')
.set('Accept', 'application/json')
.send({
email: 'a@b.com',
dateOfBirth: '2019-01-01'
})
.expect(400)
.expect(res => {
// Check the body
console.log(res.body) // Should have an error about the password
expect(res.body.message).toContain('password should not be empty') // Should have an error about the date being later than 13 years ago
expect(
res.body.message.find((m: string) => m.startsWith('maximal allowed date for dateOfBirth'))
).toBeDefined()
})
})
})
})
Right now the test will fail:
> api@0.0.1 test:e2e /Users/adam/projects/twitch/api
> jest --config ./test/jest-e2e.json FAIL test/app.e2e-spec.ts
AppController (e2e)
Auth
POST /auth/register
✕ should validate request (203 ms) ● AppController (e2e) › Auth › POST /auth/register › should validate request expected 400 "Bad Request", got 201 "Created"Ran all test suites.
Jest did not exit one second after the test run has completed.This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.
This is because we’ve not yet registered the ValidationPipe
to the test application used within the test. We'll have to add this line using the useGlobalPipes
function as we did in main.ts
:
// app.e2e-spec.ts
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile(); app = moduleFixture.createNestApplication();
// Use Validation Pipe
app.useGlobalPipes(new ValidationPipe());
await app.init();
});
Now an error appear at the bottom of the test complaining that:
Jest did not exit one second after the test run has completed.
This is occurring because the Neo4j Driver instance is left open, and therefore the Nest application doesn’t exist within the second window that Jest expects. To fix this, we’ll have to call the close
method on the app. Calling app.close()
after all of the tests have run will call the onApplicationShutdown
method on the Neo4jService
to be called, closing any sessions that are still left open.
// app.e2e-spec.ts
afterAll(() => app.close())
For the next test, the API should return a HTTP 201 Created
status with the User's information if the user supplies the correct information.
it('should return a JWT token on successful registration', () => {
const email = `${Math.random()}@adamcowley.co.uk`
return request(app.getHttpServer())
.post('/auth/register')
.set('Accept', 'application/json')
.send({
email,
firstName: 'Adam',
lastName: 'Cowley',
password: Math.random().toString(),
dateOfBirth: '2000-01-01'
})
.expect(201)
.expect(res => {
expect(res.body.user.email).toEqual(email)
})
})
Persisting the Data
The tests now pass, but nothing is happening. We next need to persist the data in the database. The AuthController shouldn’t hold any logic as to how a User is created, so this responsibility should be passed on to another class — in this case we’ll use the UserService
generated earlier. So, in src/users/user.service.ts
, we should create a new method for creating a User.
For now, we don’t have any entities in the code, so we can define the User
type as a Node from neo4j-driver
.
// user.service.ts
import { Node } from 'neo4j-driver';export type User = Node;
Then, the create
method will take named parameters so that we can reflect the business logic defined in the CreateUserDto
(email, password and dateOfBirth are required but the first and last name are optional), then pass on a cypher CREATE
query to the Neo4jService
.
// user.service.ts
import { Injectable } from '@nestjs/common';
import { Neo4jService } from '../neo4j/neo4j.service';
import { Node, types } from 'neo4j-driver';export type User = Node;@Injectable()
export class UserService { constructor(private readonly neo4jService: Neo4jService) {} async create(email: string, password: string, dateOfBirth: Date, firstName?: string, lastName?: string): Promise<User> {
const res = await this.neo4jService.write(`CREATE (u:User) SET u.id = randomUUID(), u += $properties RETURN u`, {
properties: {
email,
password,
firstName,
lastName,
dateOfBirth: types.Date.fromStandardDate(dateOfBirth),
}
}) return res.records[0].get('u');
}
}
It’s a really bad idea to store plain text passwords in the database, so we’ll install the bcrypt
library which will encrypt the plain passwords when an account is created and also check the plain text password against the encrypted value stored in the database.
npm i --save bcrypt
Next, we’ll need to create a new encryption service which will be responsible for encrypting and comparing passwords.
nest g mo encryption
nest g s encryption
The service should offer two functions, one to hash a plain text password and another to compare the password that the user has entered against the hashed valued stored in the database.
// encryption.service.ts
import { hash, compare } from 'bcrypt'
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';@Injectable()
export class EncryptionService {
constructor(private readonly config: ConfigService) {} async hash(plain: string): Promise<string> {
return hash(plain, this.config.get<number>('HASH_ROUNDS', 10))
} async compare(plain: string, encrypted: string): Promise<boolean> {
return compare(plain, encrypted)
}
}
Note: Here we’re using the ConfigService
which was previously registered as a global module inside app.module.ts
to get the HASH_ROUNDS
config value from our environment variables.
In order to inject the EncryptionService
into other services, this will need to be added to the exports
array of the EncryptionModule
.
// encryption.module.ts
import { EncryptionService } from './encryption.service';@Module({
providers: [EncryptionService],
exports: [EncryptionService],
})
export class EncryptionModule {}
The EncryptionModule
can then be added as an import to the UserModule
:
// user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { EncryptionModule } from '../encryption/encryption.module';@Module({
imports: [EncryptionModule], // <-- Import Encryption into User Service
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
…and subsequently injected into the UserService to hash the password.
// user.service.ts
export class UserService { constructor(
private readonly neo4jService: Neo4jService,
// Add Encryption Service to the constructor
private readonly encryptionService: EncryptionService
) {} async create(email: string, password: string, dateOfBirth: Date, firstName: string, lastName: string): Promise<User> {
const res = await this.neo4jService.write(`CREATE (u:User) SET u += properties RETURN u`, {
email,
// Use the encryption service to hash the password
password: await this.encryptionService.hash(password),
firstName,
lastName,
dateOfBirth: Neo4jDate.fromStandardDate(dateOfBirth)
}) return res.records[0].get('u');
}
}
Next, the AuthModule
needs to be updated to import the UserModule
:
// auth.controller.t
import { EncryptionService } from './encryption/encryption.service';@Module({
imports: [UserModule],
providers: [AuthService],
controllers: [AuthController]
})
export class AuthModule {}
Then, the UserService
can be injected and used in the AuthController
:
// auth.controller.ts
constructor(private readonly userService: UserService) {}@Post('register')
async postRegister(@Body() createUserDto: CreateUserDto) {
const user = await this.userService.create(
createUserDto.email,
createUserDto.password,
new Date(createUserDto.dateOfBirth),
createUserDto.firstName,
createUserDto.lastName
) return {
user: user.properties
}
}
If we run the test again, the two tests should now pass.
> api@0.0.1 test:e2e /Users/adam/projects/twitch/api
> jest --forceExit --config ./test/jest-e2e.json PASS test/app.e2e-spec.ts
AppController (e2e)
Auth
POST /auth/register
✓ should validate request (254 ms)
✓ should return a JWT token on successful registration (128 ms)Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.338 s, estimated 4 s
Ran all test suites.
A quick query in cypher-shell
should show that the :User
Node has been created with a hashed password:
neo4j@neo4j> MATCH (u:User) WHERE exists(u.email) RETURN u.id, u.email, u.password, u.dateOfBirth;
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------+
| u.id | u.email | u.password | u.dateOfBirth |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------+
| "d786d248-af60-49ca-b389-3fe423b9a1cf" | "0.4030098705180425@adamcowley.co.uk" | "$2b$10$YWBng/jeA7nJVZ1/aCtnG.lHLJCqDctHrVL.7SW/aHpU307xEw1Ry" | 2000-01-01 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------+
But at the moment, there is still a problem. We’re currently returning all of the user’s properties including the hashed password which isn’t a good idea, and also this method will require the user to send another request in order to log in. Instead, we should generate and return a JWT token with selected information about the user.
Generating JWT Tokens — POST /auth/login
To log in, a user will have to send a POST
request to /auth/login
with their username and password. In exchange, they will receive a JWT token containing some basic user details and an expiry timestamp.
So, the first thing to do is to create a route handler in the AuthController
:
// auth.controller.ts
@Post('login')
async postLogin(@Request() request: Request) {
// ...
}
Validating Users
To validate the user, we’ll create a validateUser
method in the AuthService
This should accept a username and plaintext password, find the User by its email address (with the help of the UserService
) and then use the EncryptionService
to check the password. If the user has been found and the password check is OK, then it should return a User
object, otherwise it should return null.
// auth.service.ts
ssync validateUser(email: string, password: string) {
const user = await this.userService.findByEmail(email) if ( user && this.encryptionService.compare(password, (user.properties as Record<string, any>).password) ) {
return user
} return null
}
NB: We should probably create an interface for a User’s properties at some point.
Then, in the UserService
, we need to create a method that will query Neo4j for a User with that email:
async findByEmail(email: string): Promise<User | undefined> {
const res = await this.neo4jService.read(`MATCH (u:User {email: $email}) RETURN u`, { email }) return res.records.length ? res.records[0].get('u') : undefined;
}
In order to generate a token, we’ll use Passport. Passport acts as a middleware, authenticating requests using using strategies. In basic terms, a strategy is a class which implements a a validate
method. The validate method will check the context of the request (ie. check the user's credentials or a token) and stop the request by throwing an error if anything goes wrong.
The @nestjs/passport
library contains all of the helper functions required to integrate Passport into Nest.
Alongside Passport, we will use Passport Local, an out-of-the-box add-on for Passport that allows you to perform basic authenticating using a Username and Password.
Installing Passport and Passport Local Dependencies
npm i --save @nestjs/passport passport passport-local
npm i --save-dev @types/passport-local
Building a Local Strategy
To implement a local strategy, we can extend the PassportStrategy
from the package and register it as a provider in the AuthModule
.
For the local-strategy, Passport expects a validate
method with the following signature: validate(username: string, password:string): any
. The strategy will be @Injectable
so we can use it in any modules that import the AuthModule
. Inside the auth folder, create a new file called local.strategy.ts
:
// local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'email' });
} async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
By default the LocalStrategy will look for a field in the request named username
, but as we want the user to log in with their email address, we can customise the behaviour by passing an object into the super()
call within the constructor stating that the usernameField
should be instead be the email
in the request.
The validate
method on this class calls the validateUser
method that we created earlier, and if a User object hasn't been returned it will halt the request by throwing an UnauthorizedException
(imported from @nestjs/common
).
Then, we need to register it as a provider in the AuthModule so that it can be used within the module.
// auth.module.ts
import { LocalStrategy } from './local.strategy'@Module({
imports: [UserModule, EncryptionModule],
providers: [AuthService, EncryptionService, LocalStrategy], // <-- appended to array
controllers: [AuthController],
})
export class AuthModule {}
Adding a Route Guard
To use the strategy above, we’ll need to create a class that extends AuthGuard
- a class provided by @nestjs/passport
.
Before a request hits the route handler function, Nest will pass the request through a pipeline of Guards which have the responsibility of validating the request and throwing an error if anything goes wrong.
In this case, the AuthGuard will extract he user’s credentials from the request and then pass it to an instance of the LocalStrategy
class. If the credentials are correct, it will add a user
item to the Request
object with whatever is returned from the validate
method, otherwise it will throw the UnauthorizedException
which will be dealt with further down the stack.
// local-auth.guard.ts
import { AuthGuard } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
We can then tell Nest to use the LocalAuthGuard
to guard the request using the UseGuards
decorator. If all goes well, the guard will set request.user
to be the user's information. If there is a problem with the user's credentials then the code in the route handler will never be touched.
// auth.controller
@UseGuards(AuthGuard('local'))
@Post('login')
async postLogin(@Request() request) {
return request.user.properties
}
Generating and Returning a JWT
This route handler will now return the user’s properties, including their password which isn’t ideal. Instead, we should be returning a JWT token. To do that, we will need passport-jwt
. Similar to passport-local
, is a strategy for authenticating users, but instead of using Username and password, it will check a token provided in the Authorization
header.
The token will also expire after the expiration time (exp
), but you can also tell passport to ignore the expiration time.
Note: It is important to remember that these keys can be easily decoded, so they shouldn’t contain any sensitive information.
To use passport-jwt
with Nest, we'll also need to install @nestjs/jwt
.
npm install @nestjs/jwt passport-jwt
npm install @types/passport-jwt --save-dev
Next, we’ll need to register the JWTService from @nestjs/jwt
with the AuthModule
. The JwtModule
requires either a secret or PEM, for now we'll add a secret key to .env
that we can then retrieve using the ConfigService
.
The values in .env
should look something like this:
# .env
JWT_SECRET=mySecret
JWT_EXPIRES_IN=30d
Then, like with the Neo4jModule
, we can import the JwtModule into the AuthModule
using the registerAsync
function.
The function takes a Dynamic Module configuration, so we can instruct Nest to import the ConfigModule
, then inject the ConfigService
into a factory function (useFactory
) which will then return the secret
and signinOptions
required to instantiate the module.
// auth.module.ts
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config';@Module({
imports: [
JwtModule.registerAsync({
imports: [ ConfigModule ],
inject: [ ConfigService, ],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN'),
},
})
}),
UserModule,
EncryptionModule,
],
providers: [AuthService, EncryptionService, LocalStrategy],
controllers: [AuthController],
})
export class AuthModule {}
JWT Strategy
Just like the Local Strategy, we also need a JWT Strategy. passport-jwt
provides an ExtractJwt.fromAuthHeaderAsBearerToken
function which we can pass through to the super
call in the PassportStrategy.
The constructor needs an instance of the ConfigService
to get the secret, otherwise it will fail to validate the token.
// jwt.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ConfigService } from "@nestjs/config";
import { ExtractJwt, Strategy } from "passport-jwt";@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
})
} async validate(payload: any) {
return this.userService.findByEmail(payload.email)
}
}
Under the hood, Passport JWT will guarantee that the token received by the validate
method is a valid token, which has been correctly signed and has not expired yet. Then it is up to us to return the information that will be assigned to user
on the Request
object. For this, I will mimic the value returned from the LocalStrategy
and return the User node from the database via the UserService
.
Note: We could also ignore the expiry date by setting ignoreExpiration: true,
in the constructor. This would be a good idea if, for example, we issued a short-life JWT token but also issued a refresh token as part of the payload. We could then check the database for the refresh token and if found, allow the request issue another short-life JWT token.
As with the LocalAuthGuard
, we'll also need to create a JWT Auth Guard.
// jwt-auth.guard.tsimport { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
Then add it as a provider for the AuthModule
:
// auth.module.ts
// ...
import { JwtStrategy } from './jwt.strategy';@Module({
// ...
providers: [AuthService, EncryptionService, LocalStrategy, JwtStrategy],
// ...
})
export class AuthModule {}
Then, to prove that the guard works, we can add it as a guard for a route handler using the @UseGuards
decorator:
// auth.controller.ts
@UseGuards(JwtAuthGuard)
@Get('user')
async getUser(@Request() request) {
const { id, email, firstName, lastName } = request.user.properties return { id, email, firstName, lastName }
}
Adding the token to the login request
Now we have can validate the token, we can add a method to generate a new JWT token to the AuthService
. To access the JWT Service, we'll need to add it to the constructor of the AuthService
// auth.service.ts
import { JwtService } from '@nestjs/jwt';// ...
@Injectable()
export class AuthService { constructor(
private readonly userService: UserService,
private readonly encryptionService: EncryptionService,
private readonly jwtService: JwtService
) {} // ...
}
As I mentioned earlier, this can be easily decoded so we should be careful about the kind of information we include in the token. In reality, we only need enough information in the token to validate that it is correct (eg. their email address) but we can also add information that can be used to customise the UI. In future, we might want to add the subscription types but for now we’ll just go with name, email and date of birth.
// auth.service.ts
async createToken(user: User) {
const {
id,
email,
dateOfBirth,
firstName,
lastName,
} = <Record<string, any>> user.properties return {
access_token: this.jwtService.sign({
sub: id,
email,
dateOfBirth,
firstName,
lastName
})
}
}
The method takes a User object (in this case a Node), pulls a set of properties from the node and uses the JwtService
to encode and sign the JWT. The result of the call to this.jwtService.sign
is a base64 encoded string similar to the one mentioned at the top of this article.
We can now hook this into the POST /auth/login
method in the AuthController
:
// auth.controller.ts
export class AuthController { constructor(
private readonly userService: UserService,
// Added authService
private readonly authService: AuthService
) {} // ... @Post('register')
async postRegister(@Body() createUserDto: CreateUserDto) {
const user = await this.userService.create(
createUserDto.email,
createUserDto.password,
new Date(createUserDto.dateOfBirth),
createUserDto.firstName,
createUserDto.lastName
) // return {
// user: user.properties
// }
return this.authService.createToken(user)
} @UseGuards(LocalAuthGuard)
@Post('login')
async postLogin(@Request() request) {
// return request.user
return this.authService.createToken(request.user)
}
}
If we head back to the end-to-end tests, we can make sure that this entire process works.
By shifting the email, usernamd and password variables into the describe('Auth', ...)
block, we can make them available to each test within that group. That way we can generate a random email address and password that can be used for the duration of the Auth tests.
// app-e2e.spec.ts
describe('Auth', () => {
const email = `${Math.random()}@adamcowley.co.uk`
const password = Math.random().toString()
let token // Tests ...
})
To test the login flow, we will need 3 tests to ensure that the API:
- Rejects a request with a bad username, returning a
401 Unauthorized
status - Rejects a request with a valid username but incorrect password
- Returns a JWT token when correct credentials are provided.
As part of the final test, I’ll also assign the returned JWT token returned by the API to the token
variable in the parent describe
block so it can be used in the GET /auth/user
later on.
// app-e2e.spec.ts
describe('POST /auth/login', () => {
it('should return 401 status on bad username', () => {
return request(app.getHttpServer())
.post('/auth/login')
.set('Accept', 'application/json')
.send({
email: 'unknown@example.com',
password: 'incorrect',
})
.expect(401)
})
it('should return 401 status on bad password', () => {
return request(app.getHttpServer())
.post('/auth/login')
.set('Accept', 'application/json')
.send({
email,
password: 'incorrect',
})
.expect(401)
}) it('should return a JWT token on successful login', () => {
return request(app.getHttpServer())
.post('/auth/login')
.set('Accept', 'application/json')
.send({
email,
password,
})
.expect(201)
.expect(res => {
expect(res.body.access_token).toBeDefined()
token = res.body.access_token
})
})
})
Given the token obtained from the Login request, does it successfully identify the user and do the details returned form the API match the original details the user registered with?
// app-e2e.spec.ts
describe('GET /auth/user', () => {
it('should authenticate user with the JWT token', () => {
return request(app.getHttpServer())
.get('/auth/user')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect(res => {
expect(res.body.email).toEqual(email)
})
})
})
Additionally, we’ll also want to test that the user won’t have access to any route with this guard if they either don’t have a token or supply an invalid token.
// app-e2e.spec.ts
it('should return error if no JWT supplied', () => {
return request(app.getHttpServer())
.get('/auth/user')
.expect(401)
})it('should return error if incorrect JWT supplied', () => {
return request(app.getHttpServer())
.get('/auth/user')
.set('Authorization', `Bearer ${token.replace(/[0-9]+/g, 'X')}`)
.expect(401)
})
In the code token.replace(/[0-9]+/g, 'X')
I'm replacing all numbers with an X
. In theory this mimics the behaviour of a would-be hacker who may try to change the payload of the token but in reality it doesn't matter what the change is as long as it invalidates the signature.
Then, finally to clean up the database, we can add an afterAll
hook to delete the user after all of the Auth tests have run.
describe('Auth', () => {
const email = `${Math.random()}@adamcowley.co.uk`
const password = Math.random().toString()
let token afterAll(() => app.get(Neo4jService).write('MATCH (n:User {email: $email}) DETACH DELETE n', { email }))
// ...
}
Recap
This was a lengthy session but we’ve covered a lot of ground. We’ve:
- Created a User module which provides a service for interacting with Users in the database. The service will allow you to find a User by its email address and create a user following business rules.
- Created an Auth service which will create users via the UserService, authenticate the User using their email address and password, and issue a JWT token to allow them to access protected API endpoints.
- Created a Guard that will read the JWT token and either permit or deny access to the protected endpoints.
- Created an Auth Controller with routes for registering and signing in.
- Added end-to-end tests to ensure that User Registration and Authentication flow works as expected
All of the code plus a write-up of each session is available on Github at https://github.com/adam-cowley/twitch-project. If you have any questions, comments or if you would like to see a feature added to the API, feel free to open a Github Issue.
Join me Tuesdays at 12:00 UTC / 13:00BST / 14:00CEST / 15:30 IST on the Neo4j Twitch Channel for the next session.