DEV Community

Elijah Trillionz
Elijah Trillionz

Posted on

Fastify CRUD API with Authentication

Hi there! Today's article is a continuation of my previous article on Fastify.

We will improve on our CRUD API in this article. You should check out the previous article else I don't think you'll be able to follow along.

Or you can just clone this repo, and follow along.

What are we introducing? Today's article is going to focus on authentication. How would we easily protect routes in Fastify? Fastify's documentation is very detailed, I recommend you go through the docs after reading this article.

Quickly let's go into our app and get things started. If you cloned the API's repo, you wanna make sure you run npm install to install all dependencies and then test all endpoints in the test.http file to ensure it's working.

In the previous article, I didn't talk about installing fastify-auth. But in the API's repo, fastify-auth is a dependency along with several others like jsonwebtoken, fastify-swagger (which we will get to in a second). So if you haven't installed the above dependencies you should do so now because we will be using them in this article.

Let's get started

Creating and Registering the Admins Routes

The first thing we want to do before protecting routes is to have a form of registering and logging in admins to the app (when connected to the front-end of course). We will have a route for registering admins, and for logging them in.

We are not connecting this API to a database, so like we did with the posts array in cloud/posts.js, that is how we would do the same for the admins.

We will have a simple array of admins in a file, have it exported and used whenever and however we want. The array can be empty initially or you can add placeholders. I will leave mine empty.

When a user creates an account, his/her details are appended to the array. As soon as he/she logs in a token will be generated for him/her. It is with this token he/she can access protected routes like that of deleting posts.

Simple right!

Alright then, let's start by creating our database array. In the cloud folder, create a file called admins.js, and add the following

const admins = [
  {
    id: 1,
    username: 'johndoe_360',
    email: 'johndoe@gmail.com_',
    password: '341',
  },
  {
    id: 2,
    username: 'sarahjohnson',
    email: 'sarah@websitename.com',
    password: 'sarahCodes',
  },
];

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

In the routes folder, create a file called admins.js and create a function called adminRoute. This function is our route plugin that we will register in server.js in a minute. This function usually takes three parameters i.e fastify, options, and done. Find more explanation on this in the previous article or in Fastify's docs.

We will create all of our admins' routes in this function. And just before the closure of the function, we wanna make sure we call done() to signify that we are done.

const adminRoutes = (fastify, options, done) => {
  // all our routes will appear here

  done();
};

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

Before we start creating routes, let's register this adminRoutes in server.js. Just after the fastify.register(require('./routes/posts')); add this

fastify.register(require('./routes/admins'));
Enter fullscreen mode Exit fullscreen mode

That should register your route and get you going, you can test it's working with a simple route e.g

fastify.get('/test', (req, reply) => {
  reply.send('Hello world');
});
Enter fullscreen mode Exit fullscreen mode

Add the code above inside the adminRoutes function in routes/admins.js and test your enpoint.

Get all Admins

This may not be useful in a real-time app, but just in case you want to get all admins, we can use the GET method to do that.

Create the Route in routes/admins.js

In place of our test route we made in adminRoutes function, we should add this

fastify.get('/api/admins', getAdminsOpts);
Enter fullscreen mode Exit fullscreen mode

Now let's create the getAdminsOpts object. This object as always should go outside of the adminRoutes function. Create and add the following

const getAdminsOpts = {
  schema: getAdminsSchema,
  handler: getAdminsHandler,
};
Enter fullscreen mode Exit fullscreen mode

Create the Schema in schemas/admins.js

This file has not been created yet, so we will create it now. In controllers/schemas folder, create a file called a admins.js. In this file create an object called getAdminsOpts.

With this schema, we want to filter out what to send to the client from our array of admins. For example, you wouldn't want to send the password of each admin to the client. So this is the easy way to do it

const getAdminsSchema = {
  response: {
    200: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          id: { type: 'number' },
          username: typeString, // typeString will be created soon
          email: typeString,
        },
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

At the beginning of this file, create a variable called typeString and assign { type: 'string' } to it.

Now let's export getAdminsSchema out of the schemas/admins.js file.

module.exports = { getAdminsSchema };
Enter fullscreen mode Exit fullscreen mode

Create the Handler in handlers/admins.js

This file has not been created yet, so let's do that now. In controllers/handlers folder, create a file called admins.js. This file will have all the handler functions of our admin routes.

Let's create our first handler, which will return all the admins we have, then we will export it.

const admins = require('../../cloud/admins'); // import the admins array

const getAdminsHandler = (req, reply) => {
  reply.send(admins);
};

module.exports = { getAdminsHandler };
Enter fullscreen mode Exit fullscreen mode

Import getAdminsHandler and getAdminsSchema into your routes/admins.js as objects.

Now save your files and test your new route.

There is a route I am going to skip, that is getting an admin, if this API was for production, I definitely would have made it. But it's not so we wouldn't need it.

Register an Admin

Let's create accounts for our new admins. So far I believe you have grasped a lot about creating routes with Fastify, so I am going to speed up the process a little bit.

I will just show you what your schema should look like and what your handler should do.

Schema

const registerAdminSchema = {
  body: {
    type: 'object',
    required: ['username', 'email', 'password'],
    properties: {
      username: typeString,
      email: typeString,
      password: typeString,
    },
  },
  response: {
    200: typeString,
  },
};
Enter fullscreen mode Exit fullscreen mode

Handler

const registerAdminHandler = (req, reply) => {
  const { username, email, password } = req.body;
  const id = admins.length + 1;

  admins.push({
    id,
    username,
    email,
    password, // you can hash the password if you want
  });

  reply.send('Account created successfully');
};
Enter fullscreen mode Exit fullscreen mode

Log in Admin

When we log in an admin, we would send a token to the client. This token will be generated using JsonWebToken (JWT). It is only with this access token the user can access protected (private) routes.

There are currently no protected routes, but we would make some posts' routes private in a moment.

First, let's see what the schema for this route should look like and how the handler should function

Schema

const loginAdminSchema = {
  body: {
    type: 'object',
    required: ['username', 'password'],
    properties: {
      username: typeString,
      password: typeString,
    },
  },
  response: {
    200: {
      type: 'object',
      properties: {
        token: typeString,
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Handler

// at the beginning of the file, import jwt and assign to a variable
const jwt = require('jsonwebtoken');

const loginAdminHandler = (req, reply) => {
  const { username, password } = req.body;

  const admin = admins.filter((admin) => {
    return admin.username === username;
  })[0];

  if (!admin) {
    return reply.send("This admin doesn't exist");
  }

  // check if password is correct
  if (password !== admin.password) {
    return reply.send('Invalid credentials');
  }

  // sign a token
  jwt.sign(
    { id: admin.id },
    'my_jwt_secret',
    { expiresIn: 3 * 86400 },
    (err, token) => {
      if (err) reply.status(500).send(new Error(err));

      reply.send({ token });
    }
  );
};
Enter fullscreen mode Exit fullscreen mode

The first thing we did was bring in jwt, you should install it first if you haven't. Use npm i jsonwebtoken to install it.
Then we did some verification to make sure the user exists and the given password is the correct password.
Lastly, we signed a token that will expire in three days with a payload of just the admin's id, you can add username and scope if you want.

If we had used it with a database and then had to get the data asynchronously using async/await we would have run into some Fastify errors. This is what I mean

const loginAdminHandler = async (req, reply) => {
  const { username, password } = req.body;

  try {
    const admin = await Admins.findOne({ username }); // assumming we used mongodb

    if (!admin) {
      return reply.send("This admin doesn't exist");
    }

    // check if password is correct
    if (password !== admin.password) {
      return reply.send('Invalid credentials');
    }

    // sign a token
    jwt.sign(
      { id: admin.id },
      'my_jwt_secret',
      { expiresIn: 3 * 86400 },
      (err, token) => {
        if (err) throw err;

        reply.send({ token });
      }
    );
  } catch (err) {
    console.log(err);
    reply.status(500).send('Server error');
  }
};
Enter fullscreen mode Exit fullscreen mode

You should note that the token we are signing is being done asynchronously. So that means our reply.send is inside an async function that is inside another async function. This can confuse Fastify to give you an error like this:

Fastify asyncawait error

Solving this is simple. We just need to tell Fastify to wait for a reply in an async function. We usually do this in the root async function i.e down the try block add await reply. That will solve the problem.

So you would have something like this

const loginAdminHandler = async (req, reply) => {
  const { username, password } = req.body;

  try {
    const admin = await Admins.findOne({ username }); // assumming we used mongodb

    if (!admin) {
      return reply.send("This admin doesn't exist");
    }

    // check if password is correct
    if (password !== admin.password) {
      return reply.send('Invalid credentials');
    }

    // sign a token
    jwt.sign(
      { id: admin.id },
      'my_jwt_secret',
      { expiresIn: 3 * 86400 },
      (err, token) => {
        if (err) throw err;

        reply.send({ token });
      }
    );

    await reply;
  } catch (err) {
    console.log(err);
    reply.status(500).send('Server error');
  }
};
Enter fullscreen mode Exit fullscreen mode

Making Private Routes

This is the simple part. Some of our routes are going to be restricted to a specific type of users. These routes are from the last article I made. Go check it out.

Private Routes
/api/posts/new : adding a post
/api/posts/edit/:id : updating a post
/api/posts/:id : deleting a post

These are the routes we will make private.

What makes a route private is the authentication that restricts unauthorized users from gaining access. If access is granted, these users can perform any action within the routes with the same authentication.

This means that we would need to authenticate the user for each of our private routes and this authentication needs to be done before any action can be performed.

With Fastify this is easy, Fastify has a plugin for easier authentication, and this authentication will be done in the preHandler function (in our routes opts).

With the fastify-auth plugin, we will tell fastify that whoever doesn't have a token should be rejected.

To do that first thing we'd do is register the fastify-auth plugin using any Fastify instance.

// in routes/posts.js, at the bottom of the postRoutes function add this
fastify
  .register(require('fastify-auth'))
  .after(() => privatePostRoutes(fastify)); // we will create the privatePostRoutes later
Enter fullscreen mode Exit fullscreen mode

The next thing is to create an authentication function. This function is what Fastify will use to validate (authenticate) the user. In our app, we need the user to have a valid token. This token would come from the request header

// create this function in an auth folder in controllers and export it
const verifyToken = (req, reply, done) => {
  const { token } = req.headers;

  jwt.verify(token, 'my_jwt_secret', (err, decoded) => {
    if (err) {
      done(new Error('Unauthorized'));
    }

    req.user = {
      id: decoded.id, // pass in the user's info
    };
  });

  done();
};
Enter fullscreen mode Exit fullscreen mode

Because we passed an error to done, Fastify will not give that user any access.

You would notice we didn't check if there is a token before verifying the token. This is because in our route's schema we restrict whoever doesn't have a token as part of the request.

Now let's apply the verifyToken function to a route. You should do this in the preHandler property in routes opts. This is the function Fastify will run first before running the handler.

Import the verifyToken into our routes/posts.js file. Create a function outside of the postRoutes function called privatePostRoutes, pass fastify as the only parameter. Now cut and paste all our private routes from postRoutes to privatePostRoutes. You should have something like this:

const postRoutes = (fastify, opts, done) => {
  // get all posts
  fastify.get('/api/posts', getPostsOpts);

  // get a post
  fastify.get('/api/posts/:id', getPostOpts);

  fastify
    .register(require('fastify-auth'))
    .after(() => privatePostRoutes(fastify));

  done();
};

const privatePostRoutes = (fastify) => {
  // create a new post
  fastify.post('/api/posts/new', addPostOpts);

  // update a post
  fastify.put('/api/posts/edit/:id', updatePostOpts);

  // delete a post
  fastify.delete('/api/posts/:id', deletePostOpts);
};
Enter fullscreen mode Exit fullscreen mode

Finally, let's add the preHandlers to our private route opts. Each of our preHandlers will contain a function from fastify-auth that checks if the user is authenticated using our verifyToken function.

const privatePostRoutes = (fastify) => {
  // create a new post
  fastify.post('/api/posts/new', {
    preHandler: fastify.auth([verifyToken]),
    ...addPostOpts,
  });

  // update a post
  fastify.put('/api/posts/edit/:id', {
    preHandler: fastify.auth([verifyToken]),
    ...updatePostOpts,
  });

  // delete a post
  fastify.delete('/api/posts/:id', {
    preHandler: fastify.auth([verifyToken]),
    ...deletePostOpts,
  });
};
Enter fullscreen mode Exit fullscreen mode

When a user is unauthorized, Fastify will return a 401 error with our customized message. If you are going to use the same auth function for more than one route, instead of importing it into each of the routes files, you can make the auth function available to every file in the API using fastify.decorate. In server.js import verifyToken and add this before your routes registering

fastify.decorate('verifyToken', verifyToken); // the string can be any name you like
Enter fullscreen mode Exit fullscreen mode

Now we can remove the verifyToken function we created in routes/posts.js and add this to our preHandlers

const privatePostRoutes = (fastify) => {
  // create a new post
  fastify.post('/api/posts/new', {
    preHandler: fastify.auth([fastify.verifyToken]),
    ...addPostOpts,
  });

  // same thing goes for the other routes
};
Enter fullscreen mode Exit fullscreen mode

Something we should add is the headers schema that will return an error whenever there is no token provided as part of the request's header.

In schemas/posts.js, create an object called headerSchema. Pass in the following

const headerSchema = {
  type: 'object',
  required: ['token'],
  properties: {
    token: typeString,
  },
};
Enter fullscreen mode Exit fullscreen mode

For every of our private route's schema, add the headerScheema object like this

const addPostSchema = {
  headers: headerSchema,
  body: {
    type: 'object',
    required: ['title', 'body'],
    properties: {
      title: typeString,
      body: typeString,
    },
  },
  response: {
    200: typeString, // sending a simple message as string
  },
};
Enter fullscreen mode Exit fullscreen mode

Your API is ready to go. Finally, let's talk about fastify-swagger. Fastify-swagger basically gives us documentation of our API, the endpoints, methods, and we can also test our endpoints with it.

We will register it as a plugin and then we are good to go. In our server.js file, add this

fastify.register(require('fastify-swagger'), {
  exposeRoute: true,
  routePrefix: '/docs',
  swagger: {
    info: { title: 'Fastify-api' },
  },
});
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:your-port/docs to see the docs.

Conclusion

Great job if you finished this project. Now you should start building more with Fastify. Source of our Fastify CRUD API

Thank you for reading. See you next time. Don't forget to buy me a coffee.

Discussion (0)