DEV Community

Cover image for Fully Serverless DERN Stack TODO App Pt. 2 - Building out our API
Adam Katora
Adam Katora

Posted on

Fully Serverless DERN Stack TODO App Pt. 2 - Building out our API

Part 2 - Building out our API & Auth system

Part. 1

If you're just joining us, in Part 1 of this series, we setup a simple express.js application, then used Claudia.js to deploy our app to AWS.

Here in Part 2, we'll be building out enough of our application that by the end you'll have a small, but functional, REST API. Since Part 1. was a lot of boilerplate Claudia.js setup, I've tried to get this Part 2 out as quickly as possible so that you can start to get an idea of what our final app will look like.

As such, I haven't been able to fully go through this write-up myself to ensure that there's no bugs in the code, and add in helpful screenshots. That'll be coming soon. I'm going to make sure the Github repo for this write-up is up to date first, so if you run into any issues, try checking there first for working code examples.

With all that out of the way, let's move on to the fun stuff, developing some features for our app. Mainly, a simple Auth system. We'll start by adding the Dynamoose package so writing some data models. We'll also add morgan, a logger middleware so that we can get info about incoming requests in the console.

From the /backend folder run the following:

npm install dynamoose morgan
Enter fullscreen mode Exit fullscreen mode

Next, inside the /backend/src create a models directory where we'll store our dynamoose models.

cd src
mkdir models
Enter fullscreen mode Exit fullscreen mode

We're going to try to keep our app simple, so we'll create 2 models. 1.) Will be a User model, with a very (read NOT production ready) basic auth system. 2.) Will be a Todo model to store information about User's Todos.

From inside the models folder create two new files for each of the models. I like to follow a [ModelName].model.js naming convention in my Express.js apps.

cd models
touch User.model.js
touch Todo.model.js
Enter fullscreen mode Exit fullscreen mode

Now, it's time to build out our models. If you've used Mongoose before, the syntax and schema of Dynamoose models should look very familiar to you.

Type the following code for our User model.

User.model.js

const dynamoose = require("dynamoose");

const userSchema = new dynamoose.Schema({
    "id": String, // UUIDv4 ID
    "username": String,
    "password": String,
}, {
    "timestamps": true
})

const User = dynamoose.model("User", userSchema)

module.exports = User
Enter fullscreen mode Exit fullscreen mode

We start by importing the dynamoose library with require("dynamoose"). Next, we define our model's schema with the dynamoose.Schema(). The first Object we pass into dynamoose.Schema() contains all the fields and their associated "attribute types" (aka data types) for our model.

You can read about the available attribute types here.

For right now, we're just going to create fields for id, username, and password.


I've mentioned this already, and I think it goes without saying but just to cover all my bases here, I wouldn't use this auth implementation in a production app. There's much better and more secure IdP services out there for developers. AWS has their Cognito IdP service, and Auth0 is another good choice. Both offer a fairly generous free-tier to allow you to get started quickly and eventually grow into a paid plan.


We also pass a second object to the .Schema() method, with some additional Schema Settings. We're setting "timestamps" to true which will automatically add createdAt & updatedAt timestamps.

Finally, we use the dynamoose.model() method, to create a new const User. The first param passed to .model is a string. This is what our model will be called. The second param we pass to .model is the object containing our SchemaDefinition and SchemaSettings, which in our case we stored in the userSchema const.

At the bottom of the file, we have a standard module.exports so that we can import the User model in other files.

With that created. Let's add the following to our Todo.model.js file.

backend/src/models/Todo.model.js

const dynamoose = require("dynamoose");

const todoSchema = new dynamoose.Schema({
    "id": String, //UUIDv4
    "user": Object,
    "title": String,
    "notes": String,
    "dueDate": String,
    "status": String,
}, {
    "timestamps": true
})

const Todo = dynamoose.model("Todo", todoSchema)

module.exports = Todo
Enter fullscreen mode Exit fullscreen mode

Our Todo model is very similar to our User model with one major difference. We added a field for user with a type of Object. We might end up changing this around later on, but that's one of the beauties of NoSQL databases, we don't have to get bogged down in too much data-modelling early on.

Now that we have our Models in place, we need to start building out how our API will interact with our models. I like to structure my Express.js apps in a bit of an MVC pattern (in this case React will be our V - view layer), and also create "Service Layers". If those two things don't make sense to you, no worries, just follow along and hopefully the project structure and code should help you make sense of those terms as we go along.

Also, if you've been following along this far, I'm going to assume you're comfortable with making new directories & files, so I'll just explain what new dirs and files we're creating, then at the end show the project structure instead of showing the bash command to create each new file.

Back inside the /src directory, make directories for routes, controllers, and services. Inside /src/routes create an index.js file and an auth.routes.js file. Inside the /src/contollers directory create a file Auth.controller.js. Inside the /src/services directory create an Auth.services.js file.

With all of those files created, this is what our project structure should look like now:

backend/
    - node_modules/
    - src/
        - controllers/
            - Auth.controller.js
        - models/
            - Todo.model.js
            - User.model.js
        - routes/
            - Auth.routes.js
            - index.js
        - services/
            - Auth.service.js
        - app.js
        - app.local.js
    - claudia.json
    - lambda.js
    - package-lock.json
    - package.json
Enter fullscreen mode Exit fullscreen mode

With those files created, let's get our router setup.

Let's start by editing our src/app.js file. Make the following changes so that your app.js file looks like this:

/src/app.js

const express = require("express")
const app = express()

// morgan for logging
const morgan = require("morgan")
app.use(morgan('dev'))

// Import Routes
app.use(require("./routes"))

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

First, we start by adding the morgan logging middleware. This will handle automatically logging to the console what requests our app receives, useful for both development and catching things that go wrong in production.

Next, we tell our app to handle all routes from our ./routes/index.js file. You'll notice that we didn't explicitly reference the /.routes/index.js file though, just the dir name.

Let's go ahead and implement our routes file now. Inside /src/routes/index.js add the following code:

/src/routes/index.js

const router = require('express').Router();
const authRoutes = require('./Auth.routes')

// Moved our API Root GET "Hello world!" here
router.get('/', (req, res) => res.send('Hello world!'))

// Import Auth routes
router.use('/api/auth', authRoutes)

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

We've moved our API Root GET request to this file to keep it organized with the other routes. We'll keep it now for testing,

In the second line of /src/routes/index.js we require() our ./Auth.routes.js file and store it as a const, authRoutes. We haven't implemented that file yet either, so let's do that now.

Inside /src/routes/Auth.routes.js file, add the following code:

/src/routes/Auth.routes.js

const router = require("express").Router()

// TODO - implement this route fully
// POST - /api/auth/register
router.post('/register', (req, res) => res.send("/register") )

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

This creates a POST endpoint for /api/auth/register which simply returns a string "/register" back to the requester.

With the boilerplate for our routing system mostly complete. This would be a good time to test everything is working before we continue much further.

Back in Postman, let's first test our "Hello world!" request to make sure that's still working from the new routes/index.js file.

Make sure the local dev server is running with:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Then use Postman to make a GET request to http://localhost:3000/ (In part 1 I promoted this to a variable {{BASE_URL}}, I'll be referencing that moving forward)

You should see the following output:

$ npm run dev

> dern-backend@1.0.0 dev
> nodemon src/app.local.js

[nodemon] 2.0.15
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node src/app.local.js`
App is listening on port 3000.
GET / 200 1.582 ms - 12
Enter fullscreen mode Exit fullscreen mode

You'll notice the output is the same as before, except the morgan middleware logged our GET request. In Postman you should see the return value of "Hello world!"

Let's also test our /api/auth/register endpoint is working. Create a new POST request in Postman for that endpoint.

In Postman you should see "/register" as the response value, and the console should have logged the new POST request:

$ npm run dev
...
POST /api/auth/register 200 2.381 ms - 9
Enter fullscreen mode Exit fullscreen mode

The next step is to setup our Controllers, these are the C in MV*C*. To briefly explain the job of Controllers, they receive the HTTP request data from the application Router. The Controller

TODO - Explain this better

Add the following code to our /src/controllers/Auth.controller.js file:
/src/controllers/Auth.controller.js

// Register New User
exports.register = async function(req, res) {
    // req validation would be handled here
    const newUserInput = req.body

    // TODO - Auth Service Register User

    res.json(newUserInput)
}
Enter fullscreen mode Exit fullscreen mode

The controller is mostly a placeholder right now, but we're saving the request body into a const newUserInput. However, we haven't implemented the express.json() middleware in order to be able to access the req.body object.

In /src/app.js add this to lines 4 & 5

/src/app.js

// Using express.json() to read req.body
app.use(express.json())
Enter fullscreen mode Exit fullscreen mode

(If you've previously used body-parser for Express.js this has essentially replaced that)

Next, update the /src/routes/Auth.routes.js file to the following to send the request to our new Controller:

/src/routes/Auth.routes.js

const router = require("express").Router()
const authController = require("../controllers/Auth.controller")

// POST - /api/auth/register
router.post('/register', authController.register)

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Since this is the first time in our application that we're dealing with request body data, this is a good opportunity to test that as well.

You should still have a POST {{BASE_URL}}/api/auth/register request. Click on the "Body" tab for that request, and click the gray dropdown box that says "none". Change that value from "none" to "raw", then in the Blue Text dropdown that appears, select "JSON".

Set the body value to the following:

{
    "username": "adam",
    "password": "adamPass"
}
Enter fullscreen mode Exit fullscreen mode

With all that set, run the request. In the console, you should see our POST request logged. Additionally, the API response should just be the request body returned back to you.

With that working, we can now implement the Service Layer of our application. To briefly explain the job of the service layer, the service layer is where the bulk of our application's business logic exists. This is where we'll put our Dynamoose calls to perform CRUD operations, and handle logic for validating users' accounts, passwords, etc.

A major benefit of moving our business logic out of the controller (or even worse, the routes) and into a service layer, is that is makes our code much more modular and re-usable.

Let's take the Auth service we're about to implement for example. We want Users to be able to register for our app. We also want them to be able to login. However, wouldn't it be a nice feature, if after a User successfully registers for our app, they're automatically logged in.

If we were to keep all of that logic inside the controllers, we would have to copy/paste the login into the register controller as well. Not terrible at first, but it can quickly become a pain to maintain that duplicate code in two places, and goes directly against the DRY Principle (Don't Repeat Yourself).

Again, don't worry if that doesn't all make sense right now, we'll implement the service layer so you can see how it all works together.

We'll need two more packages for our Auth implementation. From the /backend folder install the bcryptjs, and uuid packages with the following:

npm install bcryptjs uuid
Enter fullscreen mode Exit fullscreen mode

We'll add the following AWS SDK configuration settings to /src/app.js. Below app.use(express.json()) add the following:

...
// Dynamoose configuration
const dynamoose = require("dynamoose")
dynamoose.aws.sdk.config.update({"region": "us-east-1"});
Enter fullscreen mode Exit fullscreen mode

Side Note: Regarding AWS Authentication & Configuration -

On my dev machine, I export the Access Key, Secret Key, and Session Token into my terminal, which allows my application to quickly interact with AWS Cli & SDK services without too much configuration. If you know how to do this and can follow along as such, great.

This is what you would type into a bash terminal to export those variables:

export AWS_ACCESS_KEY_ID="[ACCESS_KEY_ID]"
export AWS_SECRET_ACCESS_KEY="[ACCESS_KEY]"
export AWS_SESSION_TOKEN="[SESSION_TOKEN]"
Enter fullscreen mode Exit fullscreen mode

Otherwise, for readers newer to AWS, I think it's probably simpler and more straight forward to configure that information in our app via code.

A major caveat of doing so is that our application will have to access sensitive information, ie our AWS ACCESS_KEY & SECRET_ACCESS_KEY. You should never hard-code sensitive information like keys & secrets into your application. Later on in this write-up, I install and configure dotenv so we can sign our JWTs with a secret.

You'll need to install with npm the dotenv package. Then, update your app.js file to include dotenv and configure it, ideally as early as possible in your application.

// Dotenv config
const dotenv = require('dotenv');
dotenv.config();

dynamoose.aws.sdk.config.update({
    "accessKeyId": process.env.AWS_ACCESS_KEY_ID
    "secretAccessKey": process.env.AWS_SECRET_ACCESS_KEY,
    "region": "us-east-1",
});
Enter fullscreen mode Exit fullscreen mode

Don't forget, you'll need a .env file in the /backend folder with the following values:

AWS_ACCESS_KEY_ID=[YOUR ACCESS KEY]
AWS_SECRET_ACCESS_KEY=[YOUR SECRET KEY]
Enter fullscreen mode Exit fullscreen mode

I still have to build out and test a working example for this, but check the github repo for pt. 2 to see the latest code examples if you're running into issues implementing this.


Then add the following to the /src/services/Auth.service.js file:

/src/services/Auth.service.js

// Import our Dynamoose User model, Bcrypt for password hashing and uuidv4
const User = require("../models/User.model")
const bcrypt = require("bcryptjs")
const {v4: uuidv4} = require("uuid")

exports.registerUser = async function(newUserInfo) {
    // newUserInfo is req.body - so it should be a JSON object ie {"username":"adam","password":"adamPass"}

    // First, check is there's already a user registered with this username
    var existingUser
    try {
        // Runs a DynamoDB scan and returns the result
        existingUser = await User.scan({username: {eq: newUserInfo.username}}).exec()
    } catch (err) {
        console.log(err)
        throw new Error(err)
    }
    // If there already is a User, throw an Error
    if(existingUser.count > 0) {
        throw new Error("EXISTING_USER_ERROR")
    } 

    // User doesn't already exist, so let's register them
    var newUser 
    try {
        const uuid = uuidv4()
        const salt = await bcrypt.genSalt(10)
        const hashedPass = await bcrypt.hash(newUserInfo.password, salt)
        newUser = await User.create({
            "id": uuid,
            "username": newUserInfo.username,
            "password": hashedPass
        })
    } catch (err) {
        console.log(err)
        throw new Error(err)
    }

    // TODO loginUser(newUser) -> return JWT w/ newUser

    return newUser
}

exports.loginUser = async function(userInfo) {
    // userInfo should be a JSON Object {"username":"adam","password":"adamPass"}
    // First, Check if the User even exists - In contrast to the above, in this case we do want there to be an existing User
    var existingUser
    try {
        existingUser = await User.scan({username: {eq: userInfo.username}}).exec()
    } catch (err) {
        console.log(err)
        throw new Error(err)
    }
    // If User doesn't exist, throw an error
    if(existingUser.count == 0) {
        throw new Error("INVALID_LOGIN_CREDENTIALS")
    }

    // Check if the supplied password matches the bcrypt hashed password saved in the User record
    var validPass
    try {
        // bcyrpt.compare will return true / false depending on if the passwords match
        // User.scan() always returns an array, hence why we specify existingUser[0].password below
        validPass = await bcrypt.compare(userInfo.password, existingUser[0].password)
    } catch (err) {
        console.log(err)
        throw new Error(err)
    }

    // If validPass is false, throw an error
    if(!validPass) {
        throw new Error("INVALID_LOGIN_CREDENTIALS")
    }

    // TODO - JWTs - We do need someway for our user to stay logged in after all

    return {"message": "Login Successful"}
}
Enter fullscreen mode Exit fullscreen mode

Update the /src/controllers/Auth.controller.js file:
/src/controllers/Auth.controller.js

const authService = require('../services/Auth.service')

// Register New User
exports.register = async function(req, res) {
    // req validation would be handled here - We're just assuming the request is properly formed
    // fine for a proof-of-concept, terrible in practice
    const newUserInput = req.body

    var newUser
    try {
        newUser = await authService.registerUser(newUserInput)
    } catch (err) {
        console.log(err)
        if(err.message == "EXISTING_USER_ERROR") {
            return res.status("422").json({"message":"User already exists"})
            // If you don't include the above return, the code will continue executing 
            // and hit the throw new Error("REGISTER_USER_ERROR") below, causing our app to crash
        }
        throw new Error(err)
    }

    res.json(newUser)
}

exports.login = async function(req, res) {
    const userInput = req.body

    var existingUser
    try {
        existingUser = await authService.loginUser(userInput)
    } catch (err) {
        console.log(err)
        if(err.message == "INVALID_LOGIN_CREDENTIALS") {
            return res.status("401").json({"message":"Invalid username or password"})
        }
        throw new Error(err)
    }

    res.json(existingUser)
}
Enter fullscreen mode Exit fullscreen mode

Lastly, don't forget to add a /api/auth/login endpoint to the /src/routes/Auth.routes.js file, add this on lines 7 & 8 below the existing /api/auth/register endpoint:

// POST - /api/auth/login
router.post('/login', authController.login)
Enter fullscreen mode Exit fullscreen mode

This is the first substantial bit of code we've written, so let's take a moment to examine what everything does. Also, I've written this to use async/await as opposed to callbacks since I think it's cleaning and easier to understand. If you're not familiar with the syntax here's some documentation that might help clarify

Starting with the Auth.service.js file, we imported our Dynamoose User model that we created earlier, we also imported bcrypt for hashing passwords, and uuidv4 to generate ids for our DynamoDB records.

Then, we created a function registerUser which accepts a single Object, newUserInfo, as a parameter. There's no type checking, or input validation implemented, but newUserInfo should consist of a string username and password. Next in the registerUser function, we check if there is already a User registered with the supplied username, if there is we return a named error "EXISTING_USER_ERROR".

If a user doesn't already exist, we precede with User creation by generating a uuid, salting & hashing the new user's password, then finally using the User.create() method (which is part of Dynamoose) to store the new user as a record in our DynamoDB table.

Once that is complete we return the newUser Object in the response body with a default status code of 200.

You'll notice that above the return line, I left a TODO comment indicating where we'll eventually call the AuthService login function (in this case it's in the same file). We'll be adding in JWT for frontend auth soon, but I wanted to include that to illustrate the benefit of implementing a service layer.

For the loginUser function in our Auth Service, the code is very similar to the registerUser function, except instead of throwing an error if a user exists, we throw an error if the user doesn't exist.

We also use the bcrypt.compare function to see if the user supplied a valid password. Since Dynamoose.scan() returns an array, in our case the existingUser variable, we have to specify existingUser[0].password when supplying the hashed password to bcrypt, otherwise existingUser.password would be undefined.

In our Auth Controller file, /src/controllers/Auth.controller.js, we imported our Auth Service file and saved it as a const authService. We then updated, the Controller's register function to make a call to the Auth Service's registerUser function.

If the Auth Service call returns an "EXISTING_USER_ERROR" error to us, we send a 422 status and error message as a response. An important thing to note about Express is that it will continue to execute code even after a call to res.send(), or res.json() is made. That is why we include the return statement immediately before res.status("422")... is called. If we didn't have the return statement, Express would continue to the next line throw new Error(err) and throw an error that would crash our app, even though we handled the error correctly.

Try removing the return statement from that line and sending a couple test requests if you want to see how that works.

In the Auth Controller login function, we make a call to the Auth Service loginUser function, and same as with register, either handle the named error, or send the return value of the authService.loginUser() call in the response.

The last thing we updated was to add the new login endpoint /api/auth/login to Auth.routes.js which should be pretty self-explanatory.

With all that new code added our app is starting to shape up. We currently have a way to register new users, and also a way to validate returning users accounts & passwords. The final piece missing, as I mentioned earlier is some sort of authentication token so our Express REST API can know when it's dealing with an authenticated user vs an unauthenticated one.


Quick aside on JWT's for API Authentication

Without trying to go into too much detail about JWTs (JSON Web Tokens) or REST API Authentication methods here, I want to briefly explain what it is we'll be doing to add JWTs to our app, and why I chose them.

Oftentimes, I feel that a lot of developers (especially in tutorials) will use JWTs just because it's latest shiny new JS toy, or because it's JS based Auth token and their writing a tutorial in JS.

While there tons more developers that choose JWTs (or different tokens) for the right reasons, I think it's beneficial to explain the pros & cons they offer and why I'm using it here.

JWTs are cryptographically signed using a secret key that (hopefully) only our app has access to. That means we can generate a JWT for our client, and when they send it back to us, we can verify wether or not the JWT was created by us.

That also means that we never have to make a call to the database, or even store our client's JWTs in a database, in order for them to be used.

This is both a pro and a con of JWTs. Assume for a minute that a hacker get's ahold of a client's JWT, they can now interact with our app as that compromised user. You might think that a simple solution is to just invalidate that JWT or add it to a denylist, but remember, we don't have either of those.

The only way to invalidate that Token would be to change the secret key our app is signing JWTs with, which would affect every user and JWT.

Since our app is simple and more of a proof-of-concept right now, we're fine using JWTs as long as we're aware of the potential security concerns. Additionally, not having to make a database call to verify a user's authentication status will work well for our current application setup.


Let's go ahead and add JWT authentication into our app. Thanks to Danny Denenberg for a nice guide on simple JWT implementation in Express. We'll need to install two new packages, jsonwebtoken to read and create JWTs and dotenv to store our JWTs secret key in a .env file.

npm install jsonwebtoken dotenv
Enter fullscreen mode Exit fullscreen mode

We are also going to create a new directory in our /src/ folder, called utils to store our JWT related code. Inside the newly create /src/utils directory. Create a file JWTauth.js.

Finally, in the /backend directory (aka the project root), create a new file .env. Note, if you put your .env file inside /src/ it won't work and you'll get undefined when you try to acccess any env variables.

/backend/.env

JWT_SECRET=secret
Enter fullscreen mode Exit fullscreen mode

(In a real app you wouldn't want to use "secret" as your JWT secret, you also wouldn't want to publish that anywhere, ie Github, etc.)

Update our /src/app.js file to read our new .env file, add the following to lines 4, 5 & 6 of app.js

/src/app.js

// Dotenv config
const dotenv = require('dotenv');
dotenv.config();
Enter fullscreen mode Exit fullscreen mode

Add the following code to the new /src/utils/JWTAuth.js file:

/src/utils/JWTAuth.js

const jwt = require('jsonwebtoken')

exports.generateAccessToken = function (username) {
    return jwt.sign({uid: username}, process.env.JWT_SECRET, {expiresIn: "2h"})
}

exports.authenticateToken = function (req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader && authHeader.split(' ')[1]

    if(token == null) {
        return res.sendStatus(401)
    }

    jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
        if(err) {
            console.log(err)
            return res.status(403)
        }

        req.user = user
        next()
    })
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's update our Register User and Login User functions in the Auth Service to generate JWTs for authenticated users.

Add this on line 5 of /src/services/Auth.service.js, it come immediately after the previous require() imports.

/src/services/Auth.services.js

const jwtAuth = require('../utils/JWTauth')
Enter fullscreen mode Exit fullscreen mode

Now, we can call the jwtAuth.generateAccessToken() function inside our Service Layer to get a valid JWT for our client.

First, we'll update the loginUser function in Auth Service to generate our JWT.

Update the final 3 lines in the loginUser function, this should start with our placeholder comment // TODO - JWTs...., you can remove that comment now.

/src/services/Auth.services.js - loginUser()

...
var authToken = await jwtAuth.generateAccessToken(existingUser[0].username)

return {token: authToken}
Enter fullscreen mode Exit fullscreen mode

Additionally, update the final 3 lines of our registerUser function in the Auth Service to make a call to loginUser.

/src/services/Auth.service.js - regiserUser()

...
var authToken = await exports.loginUser({"username": newUser.username, "password": newUserInfo.password})

return authToken
Enter fullscreen mode Exit fullscreen mode

With that code added, we can now successfully register users, then log them in and return a valid JWT. Existing users can also login with a valid username / password combination, and recieve a new valid JWT.

We've come along way to building the Auth component of our app and we're almost done. The final step is to add a new protected route that will implement our authenticateToken() middleware function we defined in the JWTauth.js file.

Open up /src/routes/Auth.routes.js and update it so that is looks like the following:

/src/routes/Auth.routes.js

const router = require("express").Router()
const authController = require("../controllers/Auth.controller")
const jwtAuth = require('../utils/JWTauth')

// POST - /api/auth/register
router.post('/register', authController.register)

// POST - /api/auth/login
router.post('/login', authController.login)

// PROTECTED ROUTE - ALL /api/auth/protected
router.all('/protected', jwtAuth.authenticateToken, authController.protected)


module.exports = router;
Enter fullscreen mode Exit fullscreen mode

You'll notice that we added a new ALL (this just means it will accept any valid HTTP request) endpoint at /api/auth/protected, and added two functions after the route declaration. The first function is our jwtAuth.authenticateToken which acts as middleware. That means that any request sent to the /api/auth/protected endpoint will first be sent to jwtAuth.authenticateToken before being sent to authController.protected. We haven't implemented the protected function in our authController so let's do that now.

Add the following code to the end of our Auth Controller:

/src/controllers/Auth.controller.js

...
exports.protected = async function(req, res) {
    console.log("Reached Protected Route")

    res.send("/protected")
}
Enter fullscreen mode Exit fullscreen mode

We should now be able to create a new user, receive a valid JWT, and use that JWT to authenticate and reach our protected endpoint.

Let's start by confirming the endpoint is inaccessible to unauthenticated users.

Back in Postman, create a new request to the endpoint /api/auth/protected. Since we used the router.all() for this endpoint you can make the request a GET or a POST or whatever else you'd like.

Send the request through, and you should see a response "Unauthorized" with status code 401.

Next, let's test registering a new user, which will in turn test the login function, by updating the body of our POST /api/auth/register request to the following:

(since our app checks the username field for existing users, we're updating that here.)

{
    "username": "adam2",
    "password": "adamPass"
}
Enter fullscreen mode Exit fullscreen mode

After sending that request through, you should get response similar to the following:

{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJhZGFtMiIsImlhdCI6MTY0NjI0MTI5MiwiZXhwIjoxNjQ2MjQ4NDkyfQ.-vaQXL5KzgeJ4Wer_sdMa-hWmovCezCRxXevEZvurfE"
}
Enter fullscreen mode Exit fullscreen mode

If you want to examine the JWT, head on over to JWT.io and copy and paste the token value into the editor. Since the secret this token was generated with is just "secret", again that's a TERRIBLE IDEA in production, you should be able to verify the token as well.

With our newly created JWT, let's copy the value, ie just this part:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJhZGFtMiIsImlhdCI6MTY0NjI0MTI5MiwiZXhwIjoxNjQ2MjQ4NDkyfQ.-vaQXL5KzgeJ4Wer_sdMa-hWmovCezCRxXevEZvurfE
Enter fullscreen mode Exit fullscreen mode

And then add it to our Postman /api/auth/protected request in the authorization header. One thing to note about working with JWTs in Auth headers, is that the token itself is usually prefixed by the term "Bearer". So in Postman >> Headers >> type in "Authorization" for the header name then add the following for the value:

Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJhZGFtMiIsImlhdCI6MTY0NjI0MTI5MiwiZXhwIjoxNjQ2MjQ4NDkyfQ.-vaQXL5KzgeJ4Wer_sdMa-hWmovCezCRxXevEZvurfE
Enter fullscreen mode Exit fullscreen mode

With that header added, resend the request. If everything goes well, instead of the "Unauthorized" response, you should now see a response body "/protected" which is what we returned in our authController.protected function. You'll also notice we should have console logged the line "Reached Protected Route" to our dev console. I added this to demonstrate that the jwtAuth.authenticateToken stops further code execution in the case of unauthorized users.

And with that, we have now implemented a Auth system, albeit a simple one, for our application. Since we covered so much ground in this section, I think this would be a good place to take a pause. In the next section, we'll start back up with deploying our newly updated app onto AWS, and test out any issues that might occur in the cloud that we're not running into on our local dev machine.

I also decided on a new name for our Todo App, "git-er-dern", which has a 2:3 pun to word ratio. Quite impressive in my humble opinion.

Discussion (0)