Authentication and Authorization in a Node API using Fastify, tRPC and Supertokens
Francisco Mendes
Posted on February 6, 2023
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
Now we need to install the base development dependencies:
yarn add -D @types/node typescript
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"]
}
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
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"
},
}
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()],
});
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>;
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();
// ...
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);
// ...
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;
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);
}
})();
If you are using monorepo, yarn link
or other methods, you can go to package.json
and add the following key:
{
"main": "src/router"
}
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.
Posted on February 6, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 6, 2023
August 19, 2022