Token injection for SRR/API with NextAuth.js (Auth.js) and Next.js

Earlier i wrote about how I used granular access via RLS to protect my database rows. This worked great to ensure the users only gets the data they have access to. This also means the APIs and frontend don't need to think about that at all.

What the frontend need to think about is different views for the user. For example, if the user is an admin, they should have access to admin management views and if they are an advertiser they should only have access to the advertiser views.

For this project I use Next.js and NextAuth.js (soon to be Auth.js. Shoutout to Balázs for his dedication and hard work on this project) to handle authentication and authorization.

The problem

I have a app that is SSR intensive and I want to protect certain pages from users that don't have the right role. I also want to protect the API endpoints.

What I want

  1. Check the users role and block/grant access to certain pages/API endpoints
  2. Inject token so I have access to the user information in SSR and API endpoints
  3. I want to infer the types for the returned props (so the type is props { ..., user } for usage in components)
  4. I don't want it to feel like im writing anything else than Next.js

To achieve this I will create a wrapper for the SSR pages and the API endpoints.

Checking the users roles

The token is stored in a a cookie via NextAuth.js and under the hood I use getToken() to decode the token and get the user information.

So we have all seen the typescript techfluencers preaching hate towards

enums
and hype
as const
instead. So naturally I will do the same.

lib / auth / index.ts

const AuthorizationRoles = {
     ADMIN: "admin",
     ADVERTISER: "advertiser",
     ALL: "all",
} as const

type AuthorizationRole =
     typeof AuthorizationRoles[keyof typeof AuthorizationRoles]

const check_access = (roles: AuthorizationRole[], token: SessionUser) => {
     if (!token) {
          return false
     }
     if (roles.includes(AuthorizationRoles.ALL)) {
          return true
     }
     if (roles.includes(AuthorizationRoles.ADMIN) && token.is_admin) {
          return true
     }
     if (roles.includes(AuthorizationRoles.ADVERTISER) && token.is_advertiser) {
          return true
     }
     return false
}

I use a simple pattern to determine if a user should have access to a view. So it checks the most lenient roles first working towards more and more specialized roles.

Securing API endpoints

I want to return

401
or
404
if the user is not authenticated or authorized. If successfull, I want to inject the token so I can use it in the API endpoint.

// lib/auth/api-wrapper.ts
import { NextApiRequest, NextApiResponse } from "next"
import { AuthorizationRole, check_access, decode_token, SessionUser } from "."

function withAuthApi<P>(
     roles: AuthorizationRole[],
     handler: (
          req: NextApiRequest,
          res: NextApiResponse<P>,
          token: SessionUser
     ) => Promise<void>
) {
     return async function (req: NextApiRequest, res: NextApiResponse<P>) {
          const decodedToken = await decode_token(req)

          if (!decodedToken || decodedToken === null) {
               res.status(401).end()
               return
          }

          if (!check_access(roles, decodedToken)) {
               res.status(403).end()
               return
          }

          // Execute the handler and inject token
          await handler(req, res, decodedToken)
     }
}

export { withAuthApi }

Usage

// api/search/playlist/[term].ts
import { withAuthApi } from "lib/auth/api-wrapper"
import type { NextApiRequest, NextApiResponse } from "next"

export default withAuthApi(
     ["all"],
     async function handler(
          req: NextApiRequest,
          res: NextApiResponse<
               { playlist_id: number; playlist_name: string }[]
          >,
          token
     ) {
          const { term } = req.query
          // Have access to token here
          const playlists = await search_playlists_db(
               token.user_id,
               term as string
          )

          res.status(200).json(playlists)
     }
)

Works perfectly.

Securing SSR pages

I need to do a lot more type casting and type checking to get this to work. I chose to return a

Redirect
if the user is not authenticated. And I return
notFound
if the user is authenticated but not authorized.

// lib/auth/ssr-wrapper.ts
import {
     GetServerSidePropsContext,
     GetServerSidePropsResult,
     Redirect,
} from "next"
import {
     AuthorizationRole,
     AuthProps,
     decode_token,
     has_access,
     SessionUser,
} from "./"

type RedirectProps = { redirect: Redirect }
type NotFound = { notFound: true }
type SuccessFullProps<P> = { props: P }

function withAuth<P>(
     roles: AuthorizationRole[],
     handler: (
          context: GetServerSidePropsContext,
          token: SessionUser
     ) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>
) {
     return async function (context: GetServerSidePropsContext) {
          const decodedToken = await decode_token(context.req)

          // Redirect to login if not authenticated
          if (!decodedToken) {
               return {
                    redirect: {
                         destination: "/login",
                         permanent: false,
                    },
               }
          }

          // Return 404 if not authorized - not sure if this is the best way to do it
          // TODO: Find a better way to handle this in ssr?
          if (!has_access(roles, decodedToken)) {
               return {
                    notFound: true,
               }
          }

          // Execute the handler and inject the token
          const external_props = (await handler(
               context,
               decodedToken
          )) as GetServerSidePropsResult<P>

          // Check if the handler returned a redirect or notFound
          if ((external_props as RedirectProps).redirect) {
               return external_props as RedirectProps
          }

          if ((external_props as NotFound).notFound) {
               return external_props as NotFound
          }

          // Return the props from the handler and the token
          return {
               props: {
                    user: decodedToken,
                    ...(external_props as SuccessFullProps<P>).props,
               } as AuthProps & P,
          }
     }
}

export { withAuth }

Usage

import { withAuth } from "lib/auth/ssr-wrapper"

export const getServerSideProps = withAuth(
     ["admin"],
     async (context, token) => {
          // Have access to the user here via the injected token
          const devices = await fetch_devices(token.user_id)

          return { props: { devices } }
     }
)

export default function Index({
     devices,
     user, // Have access to user here aswell
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
     return (
          <main className="container">
               <DevicesTable devices={devices} />
          </main>
     )
}

Smooth.

Conclusion

This solution makes it easy to add/remove roles with type safety, it's maintainable (from my perspective), it's easy to use, you get access to user data both in SSR, API and components. It also feels like you are writing Next.js code.

  1. Check the users role and block/grant access to certain pages/API endpoints ✔️
  2. Inject token so I have access to the user information in SSR and API endpoints ✔️
  3. I want to infer the types for the returned props (so the type is props { ..., user } for usage in components) ✔️
  4. I don't want it to feel like im writing anything else than Next.js ✔️