DEV Community

Cover image for Authentication and Authorization in a Node API using Fastify, tRPC and Supertokens
Francisco Mendes
Francisco Mendes

Posted on

Authentication and Authorization in a Node API using Fastify, tRPC and Supertokens

Introduction

In today's article we are going to create an API using tRPC along with a super popular Supertokens recipe to authenticate using email and password. Just as we are going to create a middleware to define whether or not we have authorization to consume certain API procedures.

The idea of today's article is to have the necessary tools to extend the example API or simply apply what you learn today in an existing API.

Prerequisites

Before going further, you need:

  • Node
  • Yarn
  • TypeScript

In addition, you are expected to have basic knowledge of these technologies.

Getting Started

Our first step will be to create the project folder:

mkdir api
cd api
yarn init -y
Enter fullscreen mode Exit fullscreen mode

Now we need to install the base development dependencies:

yarn add -D @types/node typescript
Enter fullscreen mode Exit fullscreen mode

Now let's create the following tsconfig.json:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "CommonJS",
    "allowJs": true,
    "removeComments": true,
    "resolveJsonModule": true,
    "typeRoots": ["./node_modules/@types"],
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext"],
    "baseUrl": ".",
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "moduleResolution": "Node",
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

With TypeScript configured, let's install the necessary dependencies:

yarn add fastify @fastify/formbody @fastify/cors @trpc/server zod supertokens-node

# dev dependencies
yarn add -D tsup tsx
Enter fullscreen mode Exit fullscreen mode

Now in package.json let's add the following scripts:

{
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "build": "tsup src",
    "start": "node dist/main.js"
  },
}
Enter fullscreen mode Exit fullscreen mode

Finishing the project configuration, we can now initialize Supertokens:

// @/src/auth/supertokens.ts
import supertokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import EmailPassword from "supertokens-node/recipe/emailpassword";

supertokens.init({
  framework: "fastify",
  supertokens: {
    connectionURI: "http://localhost:3567",
  },
  appInfo: {
    appName: "trpc-auth",
    apiDomain: "http://localhost:3333",
    websiteDomain: "http://localhost:5173",
    apiBasePath: "/api/auth",
    websiteBasePath: "/auth",
  },
  recipeList: [EmailPassword.init(), Session.init()],
});
Enter fullscreen mode Exit fullscreen mode

As you can see, in the code snippet above we defined the base url of our Supertokens instance (we kept the default), as well as some other configurations related to the API auth routes and frotend domain. Without forgetting to mention that the recipe that we are going to implement today is the Email and Password.

Next, let's define the tRPC context, in which we'll return the request and response objects:

// @/src/context.ts
import { inferAsyncReturnType } from "@trpc/server";
import { CreateFastifyContextOptions } from "@trpc/server/adapters/fastify";

export const createContext = ({ req, res }: CreateFastifyContextOptions) => {
  return {
    req,
    res,
  };
};

export type IContext = inferAsyncReturnType<typeof createContext>;
Enter fullscreen mode Exit fullscreen mode

With the context created and its data types inferred, we can work on the API router, starting with making the necessary imports, as well as creating the instance of the base procedure:

// @/src/router.ts
import { initTRPC, TRPCError } from "@trpc/server";
import Session from "supertokens-node/recipe/session";
import { z } from "zod";

import { IContext } from "./context";

export const t = initTRPC.context<IContext>().create();

// ...
Enter fullscreen mode Exit fullscreen mode

The next step will be to create the middleware to verify whether or not we have authorization to consume some specific procedures. If we have a valid session, we will obtain the user identifier and add it to the router context.

// @/src/router.ts
import { initTRPC, TRPCError } from "@trpc/server";
import Session from "supertokens-node/recipe/session";
import { z } from "zod";

import { IContext } from "./context";

export const t = initTRPC.context<IContext>().create();

// Middleware
const isAuthenticated = t.middleware(async ({ ctx, next }) => {
  const session = await Session.getSession(ctx.req, ctx.res);
  if (!session) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }

  return next({
    ctx: {
      session: {
        userId: session.getUserId(),
      },
    },
  });
});

const authenticatedProcedure = t.procedure.use(isAuthenticated);

// ...
Enter fullscreen mode Exit fullscreen mode

With the middleware created, we can now define the router procedures. In today's example we are going to create two procedures, getHelloMessage() which will have public access and getSession() which will require that we have a valid session started so that we can consume its data.

// @/src/router.ts
import { initTRPC, TRPCError } from "@trpc/server";
import Session from "supertokens-node/recipe/session";
import { z } from "zod";

import { IContext } from "./context";

export const t = initTRPC.context<IContext>().create();

// Middleware
const isAuthenticated = t.middleware(async ({ ctx, next }) => {
  const session = await Session.getSession(ctx.req, ctx.res);
  if (!session) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }

  return next({
    ctx: {
      session: {
        userId: session.getUserId(),
      },
    },
  });
});

const authenticatedProcedure = t.procedure.use(isAuthenticated);

// Router
export const router = t.router({
  getHelloMessage: t.procedure
    .input(
      z.object({
        name: z.string(),
      })
    )
    .query(async ({ input }) => {
      return {
        message: `Hello ${input.name}`,
      };
    }),
  getSession: authenticatedProcedure
    .output(
      z.object({
        userId: z.string().uuid(),
      })
    )
    .query(async ({ ctx }) => {
      return {
        userId: ctx.session.userId,
      };
    }),
});

export type IRouter = typeof router;
Enter fullscreen mode Exit fullscreen mode

Last but not least, we have to create the API entry file and in addition to having to configure tRPC together with Fastify, we have to make sure that we import the file where we initialize Supertokens. In order for all of this to work, we also need to ensure that we have the ideal CORS setup and that each of the plugins/middlewares are defined in the correct order.

// @/src/main.ts
import fastify from "fastify";
import cors from "@fastify/cors";
import formDataPlugin from "@fastify/formbody";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import supertokens from "supertokens-node";
import { plugin, errorHandler } from "supertokens-node/framework/fastify";

import "./auth/supertokens";
import { router } from "./router";
import { createContext } from "./context";

(async () => {
  try {
    const server = await fastify({
      maxParamLength: 5000,
    });

    await server.register(cors, {
      origin: "http://localhost:5173",
      allowedHeaders: ["Content-Type", ...supertokens.getAllCORSHeaders()],
      credentials: true,
    });

    await server.register(formDataPlugin);
    await server.register(plugin);

    await server.register(fastifyTRPCPlugin, {
      prefix: "/trpc",
      trpcOptions: { router, createContext },
    });

    server.setErrorHandler(errorHandler());

    await server.listen({ port: 3333 });
  } catch (err) {
    console.error(err);
    process.exit(1);
  }
})();
Enter fullscreen mode Exit fullscreen mode

If you are using monorepo, yarn link or other methods, you can go to package.json and add the following key:

{
  "main": "src/router"
}
Enter fullscreen mode Exit fullscreen mode

This way, when importing the router data types to the trpc client, it goes directly to the router.

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

Oldest comments (3)

Collapse
 
0xmiguel profile image
0xMiguel

Hey Francisco! Great article thank you so much for writing this :D
I realized that Supertokens sessions do not support tRPC by default, as per supertokens docs we would need to use jwt to authenticate sessions.

Collapse
 
franciscomendes10866 profile image
Francisco Mendes

Hello! Yes, the idea would be to use the Supertokens SDK on the frontend, so that Supertokens handles the session on the frontend and backend (to be an abstraction). Then, at the tRPC level, we would only need to obtain the user's session (this is what is done in the article).

In the future I may do an article on how to reconcile all of this.

Collapse
 
mattrick profile image
Matthew McCune

Excellent article. I'm not sure if Supertokens has changed since the time of posting, but it will send back a 500 instead of a 401 when a user isn't logged in and attempts to use one of the protected procedures. I believe this is because Supertokens' Session.getSession will throw by default when a user isn't logged in. To fix this, you simply need to change it to the following which makes it return undefined when a user isn't logged in:

  const session = await Session.getSession(ctx.req, ctx.res, {
    sessionRequired: false,
  });
Enter fullscreen mode Exit fullscreen mode