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
- Check the users role and block/grant access to certain pages/API endpoints
- Inject token so I have access to the user information in SSR and API endpoints
- I want to infer the types for the returned props (so the type is props { ..., user } for usage in components)
- 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.
1lib / auth / index.ts 2 3const AuthorizationRoles = { 4 ADMIN: "admin", 5 ADVERTISER: "advertiser", 6 ALL: "all", 7} as const 8 9type AuthorizationRole = 10 typeof AuthorizationRoles[keyof typeof AuthorizationRoles] 11 12const check_access = (roles: AuthorizationRole[], token: SessionUser) => { 13 if (!token) { 14 return false 15 } 16 if (roles.includes(AuthorizationRoles.ALL)) { 17 return true 18 } 19 if (roles.includes(AuthorizationRoles.ADMIN) && token.is_admin) { 20 return true 21 } 22 if (roles.includes(AuthorizationRoles.ADVERTISER) && token.is_advertiser) { 23 return true 24 } 25 return false 26}
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.
1// lib/auth/api-wrapper.ts 2import { NextApiRequest, NextApiResponse } from "next" 3import { AuthorizationRole, check_access, decode_token, SessionUser } from "." 4 5function withAuthApi<P>( 6 roles: AuthorizationRole[], 7 handler: ( 8 req: NextApiRequest, 9 res: NextApiResponse<P>, 10 token: SessionUser 11 ) => Promise<void> 12) { 13 return async function (req: NextApiRequest, res: NextApiResponse<P>) { 14 const decodedToken = await decode_token(req) 15 16 if (!decodedToken || decodedToken === null) { 17 res.status(401).end() 18 return 19 } 20 21 if (!check_access(roles, decodedToken)) { 22 res.status(403).end() 23 return 24 } 25 26 // Execute the handler and inject token 27 await handler(req, res, decodedToken) 28 } 29} 30 31export { withAuthApi }
Usage
1// api/search/playlist/[term].ts 2import { withAuthApi } from "lib/auth/api-wrapper" 3import type { NextApiRequest, NextApiResponse } from "next" 4 5export default withAuthApi( 6 ["all"], 7 async function handler( 8 req: NextApiRequest, 9 res: NextApiResponse< 10 { playlist_id: number; playlist_name: string }[] 11 >, 12 token 13 ) { 14 const { term } = req.query 15 // Have access to token here 16 const playlists = await search_playlists_db( 17 token.user_id, 18 term as string 19 ) 20 21 res.status(200).json(playlists) 22 } 23)
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.
1// lib/auth/ssr-wrapper.ts 2import { 3 GetServerSidePropsContext, 4 GetServerSidePropsResult, 5 Redirect, 6} from "next" 7import { 8 AuthorizationRole, 9 AuthProps, 10 decode_token, 11 has_access, 12 SessionUser, 13} from "./" 14 15type RedirectProps = { redirect: Redirect } 16type NotFound = { notFound: true } 17type SuccessFullProps<P> = { props: P } 18 19function withAuth<P>( 20 roles: AuthorizationRole[], 21 handler: ( 22 context: GetServerSidePropsContext, 23 token: SessionUser 24 ) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>> 25) { 26 return async function (context: GetServerSidePropsContext) { 27 const decodedToken = await decode_token(context.req) 28 29 // Redirect to login if not authenticated 30 if (!decodedToken) { 31 return { 32 redirect: { 33 destination: "/login", 34 permanent: false, 35 }, 36 } 37 } 38 39 // Return 404 if not authorized - not sure if this is the best way to do it 40 // TODO: Find a better way to handle this in ssr? 41 if (!has_access(roles, decodedToken)) { 42 return { 43 notFound: true, 44 } 45 } 46 47 // Execute the handler and inject the token 48 const external_props = (await handler( 49 context, 50 decodedToken 51 )) as GetServerSidePropsResult<P> 52 53 // Check if the handler returned a redirect or notFound 54 if ((external_props as RedirectProps).redirect) { 55 return external_props as RedirectProps 56 } 57 58 if ((external_props as NotFound).notFound) { 59 return external_props as NotFound 60 } 61 62 // Return the props from the handler and the token 63 return { 64 props: { 65 user: decodedToken, 66 ...(external_props as SuccessFullProps<P>).props, 67 } as AuthProps & P, 68 } 69 } 70} 71 72export { withAuth }
Usage
1import { withAuth } from "lib/auth/ssr-wrapper" 2 3export const getServerSideProps = withAuth( 4 ["admin"], 5 async (context, token) => { 6 // Have access to the user here via the injected token 7 const devices = await fetch_devices(token.user_id) 8 9 return { props: { devices } } 10 } 11) 12 13export default function Index({ 14 devices, 15 user, // Have access to user here aswell 16}: InferGetServerSidePropsType<typeof getServerSideProps>) { 17 return ( 18 <main className="container"> 19 <DevicesTable devices={devices} /> 20 </main> 21 ) 22}
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.
- Check the users role and block/grant access to certain pages/API endpoints ✔️
- Inject token so I have access to the user information in SSR and API endpoints ✔️
- I want to infer the types for the returned props (so the type is props { ..., user } for usage in components) ✔️
- I don't want it to feel like im writing anything else than Next.js ✔️