A LoopBack 4 application that uses JWT authentication
Page Contents

Overview

LoopBack 4 has an authentication package @loopback/authentication which allows you to secure your application’s API endpoints with custom authentication strategies and an @authenticate decorator.

This tutorial showcases how authentication was added to the loopback4-example-shopping application by creating and registering a custom authentication strategy based on the JSON Web Token (JWT) approach.

Here is a brief summary of the JSON Web Token (JWT) approach.

JSON Web Token Authentication Overview

In the JSON Web Token (JWT) authentication approach, when the user provides the correct credentials to a login endpoint, the server creates a JWT token and returns it in the response. The token is of type string and consists of 3 parts: the header, the payload, and the signature. Each part is encrypted using a secret, and the parts are separated by a period.

For example:

// {encrypted-header}.{encrypted-payload}.{encrypted-signature}
eyJhbXVCJ9.eyJpZCI6Ij.I3wpRNCH4;
// actual parts have been reduced in size for viewing purposes

After logging in and obtaining this token, whenever the user attempts to access a protected endpoint, the token must be provided in the Authorization header. The server verifies that the token is valid and not expired, and then permits access to the protected endpoint.

Please see JSON Web Token (JWT) for more details.

To view and run the completed loopback4-example-shopping application, follow the instructions in the Try it out section.

To understand the details of how JWT authentication can be added to a LoopBack 4 application, read the Adding JWT Authentication to a LoopBack 4 Application section.

Try it out

If you’d like to see the final results of this tutorial as an example application, follow these steps:

  1. Start the application:

    git clone git@github.com:strongloop/loopback4-example-shopping.git
    cd loopback4-example-shopping
    npm install
    npm run docker:start
    npm start
    

    Wait until you see:

    Recommendation server is running at http://127.0.0.1:3001.
    Server is running at http://[::1]:3000
    Try http://[::1]:3000/ping
    
  2. In a browser, navigate to http://[::1]:3000 or http://127.0.0.1:3000, and click on /explorer to open the API Explorer.

  3. In the UserController section, click on POST /users, click on 'Try it out', specify:

    {
    "id": "1",
    "email": "user1@example.com",
    "password": "thel0ngp@55w0rd",
    "firstName": "User",
    "lastName": "One"
    }
    

    and click on 'Execute' to add a new user named 'User One'.

  4. In the UserController section, click on POST /users/login, click on 'Try it out', specify:

    {
      "email":    "user1@example.com",
      "password": "thel0ngp@55w0rd"
    }
    

    and click on 'Execute' to log in as 'User One'.

    A JWT token is sent back in the response.

    For example:

    {
      "token": "some.token.value"
    }
    
  5. Perform a GET request on the secured endpoint /users/me making sure to provide the JWT token in the Authorization header. If authentication succeeds, the user profile of the currently authenticated user will be returned in the response. If authentication fails due to a missing/invalid/expired token, an HTTP 401 UnAuthorized is thrown.

    curl -X GET \
    --header 'Authorization: Bearer some.token.value' \
    http://127.0.0.1:3000/users/me
    

    The response is:

    {"id":"1","name":"User One"}
    

Adding JWT Authentication to a LoopBack 4 Application

In this section, we will demonstrate how authentication was added to the loopback4-example-shopping application using the JSON Web Token (JWT) approach.

Installing @loopback/authentication

The loopback4-example-shopping application already has the @loopback/authentication dependency set up in its package.json

It was installed as a project dependency by performing:

npm install --save @loopback/authentication

Adding the AuthenticationComponent to the Application

The core of authentication framework is found in the AuthenticationComponent, so it is important to add the component in the ShoppingApplication class in loopback4-example-shopping/packages/shopping/src/application.ts.

import {
  AuthenticationComponent
} from '@loopback/authentication';

export class ShoppingApplication extends BootMixin(
  ServiceMixin(RepositoryMixin(RestApplication)),
) {
  constructor(options?: ApplicationConfig) {
    super(options);

    // ...

    // Bind authentication component related elements
    this.component(AuthenticationComponent);

    // ...

Securing an Endpoint with the Authentication Decorator

Securing your application’s API endpoints is done by decorating controller functions with the Authentication Decorator.

The decorator’s syntax is:

@authenticate(strategyName: string, options?: object)

In the loopback4-example-shopping application, there is only one endpoint that is secured.

In the UserController class in the loopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts, a user can print out his/her user profile by performing a GET request on the /users/me endpoint which is handled by the printCurrentUser() function.

  // ...

  @get('/users/me', {
    responses: {
      '200': {
        description: 'The current user profile',
        content: {
          'application/json': {
            schema: UserProfileSchema,
          },
        },
      },
    },
  })
  @authenticate('jwt')
  async printCurrentUser(
    @inject(AuthenticationBindings.CURRENT_USER)
    currentUserProfile: UserProfile,
  ): Promise<UserProfile> {
    return currentUserProfile;
  }

  // ...

The /users/me endpoint is decorated with

@authenticate('jwt')

and authentication will only succeed if a valid JWT token is provided in the Authorization header of the request.

Basically, the AuthenticateFn action in the custom sequence MyAuthenticationSequence (discussed in a later section) asks AuthenticationStrategyProvider to resolve the registered authentication strategy with the name 'jwt' (which is JWTAuthenticationStrategy and discussed in a later section). Then AuthenticateFn calls JWTAuthenticationStrategy’s authenticate(request) function to authenticate the request.

If the provided JWT token is valid, then JWTAuthenticationStrategy’s authenticate(request) function returns the user profile. AuthenticateFn then places the user profile on the request context using the AuthenticationBindings.CURRENT_USER binding key. The user profile is available to the printCurrentUser() controller function in a variable currentUserProfile: UserProfile through dependency injection via the same AuthenticationBindings.CURRENT_USER binding key. The user profile is returned in the response.

If the JWT token is missing/expired/invalid, then JWTAuthenticationStrategy’s authenticate(request) function fails and an HTTP 401 UnAuthorized is thrown.

If an unknown authentication strategy name is specified in the @authenticate decorator:

@authenticate('unknown')

then AuthenticationStrategyProvider’s findAuthenticationStrategy(name: string) function cannot find a registered authentication strategy by that name, and an HTTP 401 UnAuthorized is thrown.

So, be sure to specify the correct authentication strategy name when decorating your endpoints with the @authenticate decorator.

Creating a Custom Sequence and Adding the Authentication Action

In a LoopBack 4 application with REST API endpoints, each request passes through a stateless grouping of actions called a Sequence.

Authentication is not part of the default sequence of actions, so you must create a custom sequence and add the authentication action.

The custom sequence MyAuthenticationSequence in loopback4-example-shopping/packages/shopping/src/sequence.ts implements the SequenceHandler interface.

export class MyAuthenticationSequence implements SequenceHandler {
  constructor(
    @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
    @inject(SequenceActions.PARSE_PARAMS)
    protected parseParams: ParseParams,
    @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
    @inject(SequenceActions.SEND) protected send: Send,
    @inject(SequenceActions.REJECT) protected reject: Reject,
    @inject(AuthenticationBindings.AUTH_ACTION)
    protected authenticateRequest: AuthenticateFn,
  ) {}

  async handle(context: RequestContext) {
    try {
      const {request, response} = context;
      const route = this.findRoute(request);

      //call authentication action
      await this.authenticateRequest(request);

      // Authentication successful, proceed to invoke controller
      const args = await this.parseParams(request, route);
      const result = await this.invoke(route, args);
      this.send(response, result);
    } catch (error) {
      //
      // The authentication action utilizes a strategy resolver to find
      // an authentication strategy by name, and then it calls
      // strategy.authenticate(request).
      //
      // The strategy resolver throws a non-http error if it cannot
      // resolve the strategy. When the strategy resolver obtains
      // a strategy, it calls strategy.authenticate(request) which
      // is expected to return a user profile. If the user profile
      // is undefined, then it throws a non-http error.
      //
      // It is necessary to catch these errors and add HTTP-specific status
      // code property.
      //
      // Errors thrown by the strategy implementations already come
      // with statusCode set.
      //
      // In the future, we want to improve `@loopback/rest` to provide
      // an extension point allowing `@loopback/authentication` to contribute
      // mappings from error codes to HTTP status codes, so that application
      // don't have to map codes themselves.
      if (
        error.code === AUTHENTICATION_STRATEGY_NOT_FOUND ||
        error.code === USER_PROFILE_NOT_FOUND
      ) {
        Object.assign(error, {statusCode: 401 /* Unauthorized */});
      }

      this.reject(context, error);
      return;
    }
  }
}

The authentication action/function is injected via the AuthenticationBindings.AUTH_ACTION binding key, is given the name authenticateRequest and has the type AuthenticateFn.

Calling

await this.authenticateRequest(request);

before

// ...
const result = await this.invoke(route, args);
this.send(response, result);
// ...

ensures that authentication has succeeded before a controller endpoint is reached.

To add the custom sequence MyAuthenticationSequence in the application, we must code the following in loopback4-example-shopping/packages/shopping/src/application.ts:

export class ShoppingApplication extends BootMixin(
  ServiceMixin(RepositoryMixin(RestApplication)),
) {
  constructor(options?: ApplicationConfig) {
    super(options);

    // ...

    // Set up the custom sequence
    this.sequence(MyAuthenticationSequence);

    // ...
  }
}

Creating a Custom JWT Authentication Strategy

When creating a custom authentication strategy, it is necessary to implement the AuthenticationStrategy interface.

A custom JWT authentication strategy JWTAuthenticationStrategy in loopback4-example-shopping/packages/shopping/src/authentication-strategies/jwt-strategy.ts was implemented as follows:

import {inject} from '@loopback/context';
import {HttpErrors, Request} from '@loopback/rest';
import {
  AuthenticationStrategy,
  UserProfile,
  TokenService,
} from '@loopback/authentication';
import {TokenServiceBindings} from '../keys';

export class JWTAuthenticationStrategy implements AuthenticationStrategy {
  name: string = 'jwt';

  constructor(
    @inject(TokenServiceBindings.TOKEN_SERVICE)
    public tokenService: TokenService,
  ) {}

  async authenticate(request: Request): Promise<UserProfile | undefined> {
    const token: string = this.extractCredentials(request);
    const userProfile: UserProfile = await this.tokenService.verifyToken(token);
    return userProfile;
  }

  extractCredentials(request: Request): string {
    if (!request.headers.authorization) {
      throw new HttpErrors.Unauthorized(`Authorization header not found.`);
    }

    // for example: Bearer xxx.yyy.zzz
    const authHeaderValue = request.headers.authorization;

    if (!authHeaderValue.startsWith('Bearer')) {
      throw new HttpErrors.Unauthorized(
        `Authorization header is not of type 'Bearer'.`,
      );
    }

    //split the string into 2 parts: 'Bearer ' and the `xxx.yyy.zzz`
    const parts = authHeaderValue.split(' ');
    if (parts.length !== 2)
      throw new HttpErrors.Unauthorized(
        `Authorization header value has too many parts. It must follow the pattern: 'Bearer xx.yy.zz' where xx.yy.zz is a valid JWT token.`,
      );
    const token = parts[1];

    return token;
  }
}

It has a name 'jwt', and it implements the async authenticate(request: Request): Promise<UserProfile | undefined> function.

An extra function extractCredentials(request: Request): string was added to extract the JWT token from the request. This authentication strategy expects every request to pass a valid JWT token in the Authorization header.

JWTAuthenticationStrategy also makes use of a token service tokenService of type TokenService that is injected via the TokenServiceBindings.TOKEN_SERVICE binding key. It is used to verify the validity of the JWT token and return a user profile.

This token service is explained in a later section.

Registering the Custom JWT Authentication Strategy

To register the custom authentication strategy JWTAuthenticationStrategy with the name 'jwt' as a part of the authentication framework, we need to code the following in loopback4-example-shopping/packages/shopping/src/application.ts.

import {registerAuthenticationStrategy} from '@loopback/authentication';

export class ShoppingApplication extends BootMixin(
  ServiceMixin(RepositoryMixin(RestApplication)),
) {
  constructor(options?: ApplicationConfig) {
    super(options);
    // ...
    registerAuthenticationStrategy(this, JWTAuthenticationStrategy);
    // ...
  }
}

Creating a Token Service

The token service JWTService in loopback4-example-shopping/packages/shopping/src/services/jwt-service.ts implements an optional helper TokenService interface.

import {inject} from '@loopback/context';
import {HttpErrors} from '@loopback/rest';
import {promisify} from 'util';
import {TokenService, UserProfile} from '@loopback/authentication';
import {TokenServiceBindings} from '../keys';

const jwt = require('jsonwebtoken');
const signAsync = promisify(jwt.sign);
const verifyAsync = promisify(jwt.verify);

export class JWTService implements TokenService {
  constructor(
    @inject(TokenServiceBindings.TOKEN_SECRET)
    private jwtSecret: string,
    @inject(TokenServiceBindings.TOKEN_EXPIRES_IN)
    private jwtExpiresIn: string,
  ) {}

  async verifyToken(token: string): Promise<UserProfile> {
    if (!token) {
      throw new HttpErrors.Unauthorized(
        `Error verifying token: 'token' is null`,
      );
    }

    let userProfile: UserProfile;

    try {
      // decode user profile from token
      const decryptedToken = await verifyAsync(token, this.jwtSecret);
      // don't copy over  token field 'iat' and 'exp', nor 'email' to user profile
      userProfile = Object.assign(
        {id: '', name: ''},
        {id: decryptedToken.id, name: decryptedToken.name},
      );
    } catch (error) {
      throw new HttpErrors.Unauthorized(
        `Error verifying token: ${error.message}`,
      );
    }

    return userProfile;
  }

  async generateToken(userProfile: UserProfile): Promise<string> {
    if (!userProfile) {
      throw new HttpErrors.Unauthorized(
        'Error generating token: userProfile is null',
      );
    }

    // Generate a JSON Web Token
    let token: string;
    try {
      token = await signAsync(userProfile, this.jwtSecret, {
        expiresIn: Number(this.jwtExpiresIn),
      });
    } catch (error) {
      throw new HttpErrors.Unauthorized(`Error encoding token: ${error}`);
    }

    return token;
  }
}

JWTService generates or verifies JWT tokens using the sign and verify functions of jsonwebtoken.

It makes use of jwtSecret and jwtExpiresIn string values that are injected via the TokenServiceBindings.TOKEN_SECRET and the TokenServiceBindings.TOKEN_EXPIRES_IN binding keys respectively.

The async generateToken(userProfile: UserProfile): Promise<string> function takes in a user profile of type UserProfile, generates a JWT token of type string using: the user profile as the payload, jwtSecret and jwtExpiresIn.

The async verifyToken(token: string): Promise<UserProfile> function takes in a JWT token of type string, verifies the JWT token, and returns the payload of the token which is a user profile of type UserProfile.

To bind the JWT secret, expires in values and the JWTService class to binding keys, we need to code the following in loopback4-example-shopping/packages/shopping/src/application.ts:

export class ShoppingApplication extends BootMixin(
  ServiceMixin(RepositoryMixin(RestApplication)),
) {
  constructor(options?: ApplicationConfig) {
    super(options);

    // ...
    this.setUpBindings();
    // ...
  }

  setUpBindings(): void {
    // ...

    this.bind(TokenServiceBindings.TOKEN_SECRET).to(
      TokenServiceConstants.TOKEN_SECRET_VALUE,
    );

    this.bind(TokenServiceBindings.TOKEN_EXPIRES_IN).to(
      TokenServiceConstants.TOKEN_EXPIRES_IN_VALUE,
    );

    this.bind(TokenServiceBindings.TOKEN_SERVICE).toClass(JWTService);

    // ...
  }
}

In the code above, TOKEN_SECRET_VALUE has a value of 'myjwts3cr3t' and TOKEN_EXPIRES_IN_VALUE has a value of '600'.

JWTService is used in two places within the application: JWTAuthenticationStrategy in loopback4-example-shopping/packages/shopping/src/authentication-strategies/jwt-strategy.ts, and UserController in loopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts.

Creating a User Service

The user service MyUserService in loopback4-example-shopping/packages/shopping/src/services/user-service.ts implements an optional helper UserService interface.

export class MyUserService implements UserService<User, Credentials> {
  constructor(
    @repository(UserRepository) public userRepository: UserRepository,
    @inject(PasswordHasherBindings.PASSWORD_HASHER)
    public passwordHasher: PasswordHasher,
  ) {}

  async verifyCredentials(credentials: Credentials): Promise<User> {
    const foundUser = await this.userRepository.findOne({
      where: {email: credentials.email},
    });

    if (!foundUser) {
      throw new HttpErrors.NotFound(
        `User with email ${credentials.email} not found.`,
      );
    }
    const passwordMatched = await this.passwordHasher.comparePassword(
      credentials.password,
      foundUser.password,
    );

    if (!passwordMatched) {
      throw new HttpErrors.Unauthorized('The credentials are not correct.');
    }

    return foundUser;
  }

  convertToUserProfile(user: User): UserProfile {
    // since first name and lastName are optional, no error is thrown if not provided
    let userName = '';
    if (user.firstName) userName = `${user.firstName}`;
    if (user.lastName)
      userName = user.firstName
        ? `${userName} ${user.lastName}`
        : `${user.lastName}`;
    return {id: user.id, name: userName};
  }
}

The async verifyCredentials(credentials: Credentials): Promise<User> function takes in a credentials of type Credentials, and returns a user of type User. It searches through an injected user repository of type UserRepository.

The convertToUserProfile(user: User): UserProfile function takes in a user of type User and returns a user profile of type UserProfile. A user profile, in this case, is the minimum set of user properties which indentify an authenticated user.

MyUserService is used in by UserController in loopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts.

To bind the MyUserService class, and the password hashing utility it uses, to binding keys, we need to code the following in loopback4-example-shopping/packages/shopping/src/application.ts:

export class ShoppingApplication extends BootMixin(
  ServiceMixin(RepositoryMixin(RestApplication)),
) {
  constructor(options?: ApplicationConfig) {
    super(options);

    // ...

    this.setUpBindings();

    // ...
  }

  setUpBindings(): void {
    // ...

    // Bind bcrypt hash services - utilized by 'UserController' and 'MyUserService'
    this.bind(PasswordHasherBindings.ROUNDS).to(10);
    this.bind(PasswordHasherBindings.PASSWORD_HASHER).toClass(BcryptHasher);

    this.bind(UserServiceBindings.USER_SERVICE).toClass(MyUserService);

    // ...
  }
}

Adding Users

In the UserController class in the loopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts, users can be added by performing a POST request to the /users endpoint which is handled by the create() function.

export class UserController {
  constructor(
    // ...
    @repository(UserRepository) public userRepository: UserRepository,
    @inject(PasswordHasherBindings.PASSWORD_HASHER)
    public passwordHasher: PasswordHasher,
    @inject(TokenServiceBindings.TOKEN_SERVICE)
    public jwtService: TokenService,
    @inject(UserServiceBindings.USER_SERVICE)
    public userService: UserService<User, Credentials>,
  ) {}

  // ...

  @post('/users')
  async create(@requestBody() user: User): Promise<User> {
    // ensure a valid email value and password value
    validateCredentials(_.pick(user, ['email', 'password']));

    // encrypt the password
    user.password = await this.passwordHasher.hashPassword(user.password);

    // create the new user
    const savedUser = await this.userRepository.create(user);
    delete savedUser.password;

    return savedUser;
  }

  // ...

A user of type User is added to the database via the user repository if the user’s email and password values are in an acceptable format.

Issuing a JWT Token on Successful Login

In the UserController class in the loopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts, a user can log in by performing a POST request, containing an email and password, to the /users/login endpoint which is handled by the login() function.

export class UserController {
  constructor(
    // ...
    @repository(UserRepository) public userRepository: UserRepository,
    @inject(PasswordHasherBindings.PASSWORD_HASHER)
    public passwordHasher: PasswordHasher,
    @inject(TokenServiceBindings.TOKEN_SERVICE)
    public jwtService: TokenService,
    @inject(UserServiceBindings.USER_SERVICE)
    public userService: UserService<User, Credentials>,
  ) {}

  // ...

  @post('/users/login', {
    responses: {
      '200': {
        description: 'Token',
        content: {
          'application/json': {
            schema: {
              type: 'object',
              properties: {
                token: {
                  type: 'string',
                },
              },
            },
          },
        },
      },
    },
  })
  async login(
    @requestBody(CredentialsRequestBody) credentials: Credentials,
  ): Promise<{token: string}> {
    // ensure the user exists, and the password is correct
    const user = await this.userService.verifyCredentials(credentials);

    // convert a User object into a UserProfile object (reduced set of properties)
    const userProfile = this.userService.convertToUserProfile(user);

    // create a JSON Web Token based on the user profile
    const token = await this.jwtService.generateToken(userProfile);

    return {token};
  }
}

The user service returns a user object when the email and password are verified as valid; otherwise it throws an HTTP 401 UnAuthorized. The user service is then called to create a slimmer user profile from the user object. Then this user profile is used as the payload of the JWT token created by the token service. The token is returned in the response.

Summary

We’ve gone through the steps that were used to add JWT authentication to the loopback4-example-shopping application.

The final ShoppingApplication class in loopback4-example-shopping/packages/shopping/src/application.ts should look like this:

import {BootMixin} from '@loopback/boot';
import {ApplicationConfig, BindingKey} from '@loopback/core';
import {RepositoryMixin} from '@loopback/repository';
import {RestApplication} from '@loopback/rest';
import {ServiceMixin} from '@loopback/service-proxy';
import {MyAuthenticationSequence} from './sequence';
import {
  TokenServiceBindings,
  UserServiceBindings,
  TokenServiceConstants,
} from './keys';
import {JWTService} from './services/jwt-service';
import {MyUserService} from './services/user-service';

import * as path from 'path';
import {
  AuthenticationComponent,
  registerAuthenticationStrategy,
} from '@loopback/authentication';
import {PasswordHasherBindings} from './keys';
import {BcryptHasher} from './services/hash.password.bcryptjs';
import {JWTAuthenticationStrategy} from './authentication-strategies/jwt-strategy';

/**
 * Information from package.json
 */
export interface PackageInfo {
  name: string;
  version: string;
  description: string;
}
export const PackageKey = BindingKey.create<PackageInfo>('application.package');

const pkg: PackageInfo = require('../package.json');

export class ShoppingApplication extends BootMixin(
  ServiceMixin(RepositoryMixin(RestApplication)),
) {
  constructor(options?: ApplicationConfig) {
    super(options);

    this.setUpBindings();

    // Bind authentication component related elements
    this.component(AuthenticationComponent);

    registerAuthenticationStrategy(this, JWTAuthenticationStrategy);

    // Set up the custom sequence
    this.sequence(MyAuthenticationSequence);

    // Set up default home page
    this.static('/', path.join(__dirname, '../public'));

    this.projectRoot = __dirname;
    // Customize @loopback/boot Booter Conventions here
    this.bootOptions = {
      controllers: {
        // Customize ControllerBooter Conventions here
        dirs: ['controllers'],
        extensions: ['.controller.js'],
        nested: true,
      },
    };
  }

  setUpBindings(): void {
    // Bind package.json to the application context
    this.bind(PackageKey).to(pkg);

    this.bind(TokenServiceBindings.TOKEN_SECRET).to(
      TokenServiceConstants.TOKEN_SECRET_VALUE,
    );

    this.bind(TokenServiceBindings.TOKEN_EXPIRES_IN).to(
      TokenServiceConstants.TOKEN_EXPIRES_IN_VALUE,
    );

    this.bind(TokenServiceBindings.TOKEN_SERVICE).toClass(JWTService);

    // // Bind bcrypt hash services
    this.bind(PasswordHasherBindings.ROUNDS).to(10);
    this.bind(PasswordHasherBindings.PASSWORD_HASHER).toClass(BcryptHasher);

    this.bind(UserServiceBindings.USER_SERVICE).toClass(MyUserService);
  }
}

Running the Completed Application

To run the completed application, follow the instructions in the Try it out section.

For more information, please visit Authentication Component.

Bugs/Feedback

Open an issue in loopback4-example-shopping and we’ll take a look!

Contributions

Tests

Run npm test from the root folder.

Contributors

See all contributors.

License

MIT