- Overview
- Try it out
- Adding JWT Authentication to a LoopBack 4 Application
- Installing @loopback/authentication
- Adding the AuthenticationComponent to the Application
- Securing an Endpoint with the Authentication Decorator
- Creating a Custom Sequence and Adding the Authentication Action
- Creating a Custom JWT Authentication Strategy
- Registering the Custom JWT Authentication Strategy
- Creating a Token Service
- Creating a User Service
- Adding Users
- Issuing a JWT Token on Successful Login
- Summary
- Running the Completed Application
- Bugs/Feedback
- Contributions
- Tests
- Contributors
- License
A LoopBack 4 application that uses JWT authentication
Overview
LoopBack 4 has an authentication package @loopback/authentication
which allowsyou to secure your application’s API endpoints with custom authenticationstrategies and an @authenticate
decorator.
This tutorial showcases how authentication
was added to theloopback4-example-shoppingapplication by creating and registering a custom authentication strategybased on the JSON Web Token (JWT)
approach.
Here is a brief summary of the JSON Web Token (JWT)
approach.
In the JSON Web Token (JWT) authentication approach, when the user providesthe correct credentials to a login endpoint, the server creates a JWTtoken and returns it in the response. The token is of type string andconsists of 3 parts: the header, the payload, and the signature.Each part is encrypted using a secret, and the parts are separated by aperiod.
For example:
// {encrypted-header}.{encrypted-payload}.{encrypted-signature}
eyJhbXVCJ9.eyJpZCI6Ij.I3wpRNCH4;
// actual parts have been reduced in size for viewing purposes
Note:The payload can contain anythingthe application developer wants, but at the very least contains the user id. It should never contain the user password.
After logging in and obtaining this token, whenever the user attempts to accessa protected endpoint, the token must be provided in the Authorizationheader. The server verifies that the token is valid and not expired, and thenpermits access to the protected endpoint.
Please see JSON Web Token (JWT)for more details.
To view and run the completed loopback4-example-shopping
application, followthe instructions in the Try it out section.
To understand the details of how JWT authentication can be added to a LoopBack 4application, read theAdding JWT Authentication to a LoopBack 4 Applicationsection.
Try it out
If you’d like to see the final results of this tutorial as an exampleapplication, follow these steps:
- Start the application:
git clone https://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
In a browser, navigate to http://[::1]:3000 orhttp://127.0.0.1:3000, and click on
/explorer
toopen theAPI Explorer
.In the
UserController
section, click onPOST /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'
.
- In the
UserController
section, click onPOST /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"
}
- Perform a
GET
request on the secured endpoint/users/me
making sure toprovide the JWT token in theAuthorization
header. If authenticationsucceeds, theuser profileof the currently authenticated user will be returned in the response. Ifauthentication fails due to a missing/invalid/expired token, anHTTP 401 UnAuthorizedis 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 theloopback4-example-shoppingapplication using theJSON 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 theAuthenticationComponent,so it is important to add the component in the ShoppingApplication
class inloopback4-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 controllerfunctions with theAuthentication Decorator.
The decorator’s syntax is:
@authenticate(strategyName: string, options?: object)
In the loopback4-example-shopping
application, there is only one endpoint thatis secured.
In the UserController
class in theloopback4-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(SecurityBindings.USER)
currentUserProfile: UserProfile,
): Promise<UserProfile> {
return currentUserProfile;
}
// ...
Note:Since this controller method is obtaining CURRENT_USER via method injection (instead of constructor injection) and this method is decorated with the @authenticate decorator, there is no need to specify @inject(SecurityBindings.USER, {optional:true}). See Using the Authentication Decorator for details.
The /users/me
endpoint is decorated with
@authenticate('jwt')
and authentication will only succeed if a valid JWT token is provided in theAuthorization
header of the request.
Basically, theAuthenticateFnaction in the custom sequence MyAuthenticationSequence
(discussed in a latersection) asksAuthenticationStrategyProviderto resolve the registered authentication strategy with the name 'jwt'
(whichis JWTAuthenticationStrategy
and discussed in a later section). ThenAuthenticateFn
calls JWTAuthenticationStrategy
’s authenticate(request)
function to authenticate the request.
If the provided JWT token is valid, then JWTAuthenticationStrategy
’sauthenticate(request)
function returns the user profile. AuthenticateFn
thenplaces the user profile on the request context using the SecurityBindings.USER
binding key. The user profile is available to the printCurrentUser()
controller function in a variable currentUserProfile: UserProfile
throughdependency injection via the same SecurityBindings.USER
binding key. The userprofile is returned in the response.
If the JWT token is missing/expired/invalid, then JWTAuthenticationStrategy
’sauthenticate(request)
function fails and anHTTP 401 UnAuthorizedis thrown.
If an unknown authentication strategy name is specified in the@authenticate
decorator:
@authenticate('unknown')
thenAuthenticationStrategyProvider’sfindAuthenticationStrategy(name: string)
function cannot find a registeredauthentication strategy by that name, and anHTTP 401 UnAuthorizedis thrown.
So, be sure to specify the correct authentication strategy name when decoratingyour 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 througha stateless grouping of actions called a Sequence.
Authentication is not part of the default sequence of actions, so you mustcreate a custom sequence and add the authentication action.
The custom sequence MyAuthenticationSequence
inloopback4-example-shopping/packages/shopping/src/sequence.ts
implements theSequenceHandlerinterface.
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 theAuthenticationBindings.AUTH_ACTION
binding key, is given the nameauthenticateRequest
and has the typeAuthenticateFn.
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 isreached.
To add the custom sequence MyAuthenticationSequence
in the application, wemust code the following inloopback4-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 theAuthenticationStrategyinterface.
A custom JWT authentication strategy JWTAuthenticationStrategy
inloopback4-example-shopping/packages/shopping/src/authentication-strategies/jwt-strategy.tswas 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 theasync authenticate(request: Request): Promise<UserProfile | undefined>
function.
An extra function extractCredentials(request: Request): string
was added toextract the JWT token from the request. This authentication strategy expectsevery request to pass a valid JWT token in the Authorization
header.
JWTAuthenticationStrategy
also makes use of a token service tokenService
oftype TokenService
that is injected via theTokenServiceBindings.TOKEN_SERVICE
binding key. It is used to verify thevalidity 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
withthe name 'jwt'
as a part of the authentication framework, we need to codethe following inloopback4-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
inloopback4-example-shopping/packages/shopping/src/services/jwt-service.ts
implements an optional helperTokenServiceinterface.
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 areinjected via the TokenServiceBindings.TOKEN_SECRET
and theTokenServiceBindings.TOKEN_EXPIRES_IN
binding keys respectively.
The async generateToken(userProfile: UserProfile): Promise<string>
functiontakes in a user profile of typeUserProfile,generates a JWT token of type string
using: the user profile as thepayload, jwtSecret and jwtExpiresIn.
The async verifyToken(token: string): Promise<UserProfile>
function takes in aJWT token of type string
, verifies the JWT token, and returns the payload ofthe token which is a user profile of type UserProfile
.
To bind the JWT secret
, expires in
values and the JWTService
class tobinding keys, we need to code the following inloopback4-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'
andTOKEN_EXPIRES_IN_VALUE
has a value of '600'
.
JWTService
is used in two places within the application:JWTAuthenticationStrategy
inloopback4-example-shopping/packages/shopping/src/authentication-strategies/jwt-strategy.ts
,and UserController
inloopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts
.
Creating a User Service
The user service MyUserService
inloopback4-example-shopping/packages/shopping/src/services/user-service.ts
implements an optional helperUserServiceinterface.
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>
functiontakes in a credentials of typeCredentials,and returns a user of typeUser.It searches through an injected user repository of type UserRepository
.
The convertToUserProfile(user: User): UserProfile
function takes in a userof type User
and returns a user profile of typeUserProfile.A user profile, in this case, is the minimum set of user properties whichindentify an authenticated user.
MyUserService
is used in by UserController
inloopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts
.
To bind the MyUserService
class, and the password hashing utility it uses, tobinding keys, we need to code the following inloopback4-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 theloopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts
,users can be added by performing a POST
request to the /users
endpoint whichis 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 typeUseris added to the database via the user repository if the user’s email andpassword values are in an acceptable format.
Issuing a JWT Token on Successful Login
In the UserController
class in theloopback4-example-shopping/packages/shopping/src/controllers/user.controller.ts
,a user can log in
by performing a POST
request, containing an email
andpassword
, 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 verifiedas valid; otherwise it throws anHTTP 401 UnAuthorized.The user service is then called to create a slimmer user profile from the userobject. Then this user profile is used as the payload of the JWT token createdby 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 theloopback4-example-shopping
application.
The final ShoppingApplication
class inloopback4-example-shopping/packages/shopping/src/application.tsshould 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 theTry it out section.
For more information, please visitAuthentication Component.
Bugs/Feedback
Open an issue inloopback4-example-shoppingand we’ll take a look!
Contributions
Tests
Run npm test
from the root folder.
Contributors
Seeall contributors.
License
MIT