Dino: Build a Rest API with JWT
On the eve of the start of the course “Node.JS Developer” we invite everyone to watch an open lesson on the topic “Dockerizing Node.js Applications”…
And now we are sharing the traditional translation of useful material. Enjoy reading.
Since the first version, Deno has become a buzzword for Javascript / TypeScript / Node. Let’s dive into this technology by creating a JWT-secured REST API.
It is advisable to already have some basics in Node and its ecosystem (Express, Nodemon, Sequelize, etc.) in order to follow this tutorial.
What is Dino (Deno)?
Deno is a simple, modern and secure JavaScript and TypeScript runtime that uses V8 and is built into Rust.
There are already many articles detailing this topic, so I will not dwell on it. I can recommend this…
Introduction
Since the official release V1Deno has become a buzzword in a matter of weeks (for the fan, here is the popularity curve for “deno” searches on Google).
What can you do with a secure runtime for Typescript and Javascript “?
To better understand and give my opinion on this growing project, I decided to create a secure JWT REST API and share my feelings with you.
I am used to working with Node.js and Express…
goal
The goal of this tutorial will be to create a secure REST API, which means:
Server Tuning
Creating a model with ORM and database
CRUD user
Implementing secure routing with JWT
Premise
To create our REST API with JWT, I will use:
Deno (I recommend the official documentation for installation: here)
VSCode and Deno support plugin available from link
And also the following packages (I’ll come back to this throughout the tutorial):
Installation
First, let’s set up the project structure to provide specific guidance for creating a clean and “production-ready” project.
|-- DenoRestJwt
|-- controllers/
| |-- database/
| |-- models/
|-- helpers/
|-- middlewares/
|-- routers/
|-- app.ts
If we were on a Node + Express application, I would use Nodemon to ease development, Nodemon restarts the server automatically after changes in the code.
Nodemon is a tool that helps you develop node.js based applications by automatically restarting your Node application when it detects changes to a file in a directory.
To keep the same “development comfort”, I decided to use Denon, its counterpart for Deno.
deno install --allow-read --allow-run --allow-write -f --unstable
https://deno.land/x/denon/denon.ts
Let’s tweak the Denon configuration a bit. This will be useful later (especially for managing environment variables).
// into denon.json
{
"$schema": "https://deno.land/x/denon/schema.json",
"env": {},
"scripts": {
"start": {
"cmd": "deno run app.ts"
}
}
}
We are now ready to start coding in good conditions! To start Denon, just type in the console denon start
:
➜ denon start
[denon] v2.0.2
[denon] watching path(s): *.*
[denon] watching extensions: ts,js,json
[denon] starting `deno run app.ts`
Compile file:///deno-crashtest/app.ts
[denon] clean exit - waiting for changes before restart
You can see that our server is running … but it crashes! This is ok, it has no code to execute in app.ts
…
Let’s initialize our server
I decided to use the framework Oak…
Oak is a middleware framework for Deno http server including router middleware. This middleware framework is inspired by Koa and middleware is inspired by @koa/router
…
Let’s initialize our server with Oak:
// app.ts
import { Application, Router, Status } from "https://deno.land/x/oak/mod.ts";
// Initialise app
const app = new Application();
// Initialise router
const router = new Router();
// Create first default route
router.get("https://habr.com/", (ctx) => {
ctx.response.status = Status.OK;
ctx.response.body = { message: "It's work !" };
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log("? Deno start !");
await app.listen("0.0.0.0:3001");
Now if we start our server with denon start
…
error: Uncaught PermissionDenied: network access to "0.0.0.0:3001",
run again with the --allow-net flag
This is one of the big differences between Deno and Node: Deno is secure by default and does not have access to network
… You must authorize it:
// into denon.json
"scripts": {
"start": {
// add --allow-net
"cmd": "deno run --allow-net app.ts"
}
}
You can now access from your browser (although I recommend using Postman) to localhost: 3001 :
{
"message": "It's work !"
}
Installing the database
I’ll use DenoDB as an ORM (in particular because it supports Sqlite3). Moreover, it is very similar to Sequelize (to which I’m used to).
Let’s add the first controller Database
and the file Sqlite3.
|-- DenoRestJwt
|-- controllers/
| |-- Database.ts
| |-- database/
| | |-- db.sqlite
| |-- models/
|-- app.ts
// Database.ts
import { Database } from "https://deno.land/x/denodb/mod.ts";
export class DatabaseController {
client: Database;
/**
* Initialise database client
*/
constructor() {
this.client = new Database("sqlite3", {
filepath: Deno.realPathSync("./controllers/database/db.sqlite"),
});
}
/**
* Initialise models
*/
async initModels() {
this.client.link([]);
await this.client.sync({});
}
}
Our ORM is initialized. You can notice that I am using realPathSync
which requires additional permission. Let’s add --allow-read
unfinished and --allow-write
unfinished in denon.json
:
"scripts": {
"start": {
"cmd": "deno run --allow-write --allow-read --allow-net app.ts"
}
}
All that’s left to do is create a user model through our ORM:
|-- DenoRestJwt
|-- controllers/
| |-- models/
| |-- User.ts
|-- app.ts
// User.ts
import { Model, DATA_TYPES } from "https://deno.land/x/denodb/mod.ts";
import nanoid from "https://deno.land/x/nanoid/mod.ts";
export interface IUser {
id?: string;
firstName: string;
lastName: string;
password: string;
}
export class User extends Model {
static table = "users";
static timestamps = true;
static fields = {
id: {
primaryKey: true,
type: DATA_TYPES.STRING,
},
firstName: {
type: DATA_TYPES.STRING,
},
lastName: {
type: DATA_TYPES.STRING,
},
password: {
type: DATA_TYPES.TEXT,
},
};
// Id will generate a nanoid by default
static defaults = {
id: nanoid(),
};
}
There is nothing new here, so I will not dwell on this. (ps: I am using nanoid
for managing my UUID, I will let you read this very interesting article about it).
I am taking this opportunity to add a function that will be useful later: a password hash. For this I use Bcrypt:
// inside User's class
import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
// ...
static async hashPassword(password: string) {
const salt = await bcrypt.genSalt(8);
return bcrypt.hash(password, salt);
}
Finally, let’s link our model to our ORM:
// Database.ts
import { User } from "./models/User.ts";
export class DatabaseController {
//...
initModels() {
// Add User here
this.client.link([User]);
return this.client.sync({});
}
}
Good! Now that our server and database are in place, it’s time to initialize the account creation routes …
User controller
There is nothing more basic than a good CRUD:
|-- DenoRestJwt
|-- controllers/
| |-- Database.ts
| |-- UserController.ts
import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
import { IUser, User } from "./models/index.ts";
export class UserController {
async create(values: IUser) {
// Call static user method
const password = await User.hashPassword(values.password);
const user: IUser = {
firstName: values.firstName,
lastName: values.lastName,
password,
};
await User.create(user as any);
return values;
}
async delete(id: string) {
await User.deleteById(id);
}
getAll() {
return User.all();
}
getOne(id: string) {
return User.where("id", id).first();
}
async update(id: string, values: IUser) {
await User.where("id", id).update(values as any);
return this.getOne(id);
}
async login(lastName: string, password: string) {
const user = await User.where("lastName", lastName).first();
if (!user || !(await bcrypt.compare(password, user.password))) {
return false;
}
// TODO generate JWT
}
}
I am just using the methods provided by the ORM. Now we just have to manage the generation of the JWT.
Setting up routing
Now it’s time to create our various paths and call our freshly encoded controller.
|-- DenoRestJwt
|-- routers
|-- UserRoute.ts
import { Router, Status } from "https://deno.land/x/oak/mod.ts";
import { UserController } from "../controllers/UserController.ts";
import { BadRequest } from "../helpers/BadRequest.ts";
import { NotFound } from "../helpers/NotFound.ts";
// instantiate our controller
const controller = new UserController();
export function UserRoutes(router: Router) {
return router
.get("/users", async (ctx) => {
const users = await controller.getAll();
if (users) {
ctx.response.status = Status.OK;
ctx.response.body = users;
} else {
ctx.response.status = Status.NotFound;
ctx.response.body = [];
}
return;
})
.post("/login", async (ctx) => {
if (!ctx.request.hasBody) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
// TODO generate JWT
ctx.response.status = Status.OK;
ctx.response.body = { jwt };
})
.get("/user/:id", async (ctx) => {
if (!ctx.params.id) {
return BadRequest(ctx);
}
const user = await controller.getOne(ctx.params.id);
if (user) {
ctx.response.status = Status.OK;
ctx.response.body = user;
return;
}
return NotFound(ctx);
})
.post("/user", async (ctx) => {
if (!ctx.request.hasBody) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
const user = await controller.create(value);
if (user) {
ctx.response.status = Status.OK;
ctx.response.body = user;
return;
}
return NotFound(ctx);
})
.patch("/user/:id", async (ctx) => {
if (!ctx.request.hasBody || !ctx.params.id) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
const user = await controller.update(ctx.params.id, value);
if (user) {
ctx.response.status = Status.OK;
ctx.response.body = user;
return;
}
return NotFound(ctx);
})
.delete("/user/:id", async (ctx) => {
if (!ctx.params.id) {
return BadRequest(ctx);
}
await controller.delete(ctx.params.id);
ctx.response.status = Status.OK;
ctx.response.body = { message: "Ok" };
});
}
All we need to do is call our logic from our controller.
I am using HTTP methods to clearly separate routes. I also created helpers to manage the returned errors. The sources can be found directly from the GitHub project! All we have to do is call our router in our application:
// app.ts
import { DatabaseController } from "./controllers/Database.ts";
import { UserRoutes } from "./routers/UserRoute.ts";
const userRoutes = UserRoutes(router);
app.use(userRoutes.routes());
app.use(userRoutes.allowedMethods());
await new DatabaseController().initModels();
Security and JWT
It’s time to add security to this project! I use JWT for this.
1. Create a secure route
First of all, we are going to install the middle layer:
Checks if the “Authorization” header exists in the request.
Pulls out the title
Validates the title
Returns an error / Accepts the request and calls the private route
I will use the library Djwt…
|-- DenoRestJwt
|-- middlewares/
| |-- jwt.ts
Our function will have to accept the request context in the parameter, extract the token from the headers, check its validity and act accordingly.
import { Context, Status } from "https://deno.land/x/oak/mod.ts";
import { validateJwt } from "https://deno.land/x/djwt/validate.ts";
/**
* Create a default configuration
*/
export const JwtConfig = {
header: "Authorization",
schema: "Bearer",
// use Env variable
secretKey: Deno.env.get("SECRET") || "",
expirationTime: 60000,
type: "JWT",
alg: "HS256",
};
export async function jwtAuth(
ctx: Context<Record<string, any>>,
next: () => Promise<void>
) {
// Get the token from the request
const token = ctx.request.headers
.get(JwtConfig.header)
?.replace(`${JwtConfig.schema} `, "");
// reject request if token was not provide
if (!token) {
ctx.response.status = Status.Unauthorized;
ctx.response.body = { message: "Unauthorized" };
return;
}
// check the validity of the token
if (
!(await validateJwt(token, JwtConfig.secretKey, { isThrowing: false }))
) {
ctx.response.status = Status.Unauthorized;
ctx.response.body = { message: "Wrong Token" };
return;
}
// JWT is correct, so continue and call the private route
next();
}
Please note that we need a secret key in order to encrypt our token. I use Deno environment variables for this. So we need to make a few changes to Denon’s configuration: add our variable and let Deno receive environment variables.
{
"$schema": "<https://deno.land/x/denon/schema.json>",
// Add env variable
"env": {
"SECRET": "ADRIEN_IS_THE_BEST_AUTHOR_ON_MEDIUM"
},
"scripts": {
"start": {
// add the permission with --allow-env
"cmd": "deno run --allow-env --allow-read --allow-net app.ts"
}
}
}
(ps: if you want secure environment variables, i recommend this tutorial)
Then let’s create our private route.
|-- DenoRestJwt
|-- routers
|-- UserRoute.ts
|-- PrivateRoute.ts
Just call our method before calling our route:
import { Router, Status } from "https://deno.land/x/oak/mod.ts";
import { jwtAuth } from "../middlewares/jwt.ts";
export function PrivateRoutes(router: Router) {
// call our middleware before our private route
return router.get("/private", jwtAuth, async (ctx) => {
ctx.response.status = Status.OK;
ctx.response.body = { message: "Conntected !" };
});
}
Don’t forget to add it to our application:
import { Router, Status } from "https://deno.land/x/oak/mod.ts";
import { jwtAuth } from "../middlewares/jwt.ts";
export function PrivateRoutes(router: Router) {
// call our middleware before our private route
return router.get("/private", jwtAuth, async (ctx) => {
ctx.response.status = Status.OK;
ctx.response.body = { message: "Conntected !" };
});
}
If we try to call our API on / private, we get the correct answer:
{
"message": "Unauthorized"
}
2. JWT generation
Now it’s time to set up token generation when users log in. Remember, we left // TODO generate JWT in our controller. Before completing it, we’ll first add a static method to our User model to generate a token.
// User.ts
import {
makeJwt,
setExpiration,
Jose,
Payload,
} from "https://deno.land/x/djwt/create.ts";
import { JwtConfig } from "../../middlewares/jwt.ts";
// ...
export class User extends Model {
// ...
static generateJwt(id: string) {
// Create the payload with the expiration date (token have an expiry date) and the id of current user (you can add that you want)
const payload: Payload = {
id,
exp: setExpiration(new Date().getTime() + JwtConfig.expirationTime),
};
const header: Jose = {
alg: JwtConfig.alg as Jose["alg"],
typ: JwtConfig.type,
};
// return the generated token
return makeJwt({ header, payload, key: JwtConfig.secretKey });
}
// ...
}
Let’s call this method in our controller:
// UserController.ts
export class UserController {
// ...
async login(lastName: string, password: string) {
const user = await User.where("lastName", lastName).first();
if (!user || !(await bcrypt.compare(password, user.password))) {
return false;
}
// Call our new static method
return User.generateJwt(user.id);
}
}
Finally, let’s add this logic to our router:
// UserRoute.ts
// ...
.post("/login", async (ctx) => {
if (!ctx.request.hasBody) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
// generate jwt
const jwt = await controller.login(value.lastName, value.password);
if (!jwt) {
return BadRequest(ctx);
}
ctx.response.status = Status.OK;
// and return it
ctx.response.body = { jwt };
})
// ...
Now, if we try to connect, we have:
// localhost:3001/login
{
"jwt":
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IlEyY0ZZcUxKWk5Hc0toN0FWV0hzUiIsImV4cCI6MTU5MDg0NDU2MDM5MH0.drQ3ay5_DYuXEOnH2Z0RKbhq9nZElWCMvmypjI4BjIk"
}
(Don’t forget to create an account earlier)
Let’s add this token to our headers Authorization
and call our private route again:
// localhost:3001/private with token in headers
{
"message": "Connected !"
}
Great! Is there our secure API?
You can find this project on my Github: here (I am adding postman collection to make a request).
My impressions of Deno
I decided to share with you my impressions of Depo, which will give you some idea of it:
Importing modules by URL at the beginning is a bit counter-intuitive: you always want to do npm i
or yarn add
… Moreover, we have to run Deno to cache our imports, and only then do we have access to auto-completion.
The remote module XXX has not been cached
I always use TypeScript in my Javascript projects, so I didn’t get lost in the beginning. On the contrary, I am quite familiar with him.
Interesting point: permissions. I think it’s good that Deno, for example, requires permissions to access the network. This makes us, as developers, aware of the access and rights of our program. (more safely)
At first, we got a little confused about where to look for packages (https://deno.land/x -> 460 packages and NPM -> + 1 million).
You can never be sure if a package also works for Deno or not. You + always want to be closer to what you know and use in Node in order to port it to Deno. I don’t know if this is good or bad, it’s still javascript …
Learn more about the course “Node.JS Developer”…
View an open lesson on the topic “Dockerizing Node.js Applications”…
