AuthN and AuthZ with TRPC
This article dives into practical ways to secure your tRPC endpoints using middleware and private procedures within an Express environment. We’ll assume you’re familiar with the core concepts of authentication (AuthN) and authorization (AuthZ) and focus on the implementation details.
Context
Attach the authorization token into the context. This context will get called on all requests so lets keep it light.
import { CreateExpressContextOptions } from '@trpc/server/adapters/express'
export const createTRPCContext = ({
req,
res,
}: CreateExpressContextOptions) => {
const header = req.headers.authorization
const token = header?.split(' ')[1]
// Bearer eylkdflk
return { req, res, token }
}
TRPC Instance
The ‘t’ in the below code is the Trpc instance and it acts as a gateway to various tRPC functionalities, including defining routes, procedures, and utilizing middleware.
import { initTRPC } from '@trpc/server'
import { createTRPCContext } from './context'
export const t = initTRPC.context<typeof createTRPCContext>().create()
Notice I included the context while creating this makes sure the context we access inside the procedure is typed with the token string.
Now we can take the token and verify it inside the queries and mutations but lets use middleware for that.
Middleware
Lets create a isAuthed middleware. This is going to be used for both Authentication and Authorization of the requester.
Requirements
- Acts as a gatekeeper for procedures, enforcing authentication and authorization.
- Verifies provided tokens and extracts user information.
- Checks user roles against required roles for authorized access.
- Adds uid to the context for use within procedures.
Design
- Wraps procedures to apply
isAuthed
middleware, ensuring only authenticated and authorized users can access them. - Accepts optional
roles
argument to specify required roles for access.
export const privateProcedure = (...roles: Role[]) =>
t.procedure.use(isAuthed(...roles))
Usage
The privateProcedure is versatile to be used for creating authenticated routes and role based routes.
Also remember the ctx now has uid.
export const authRoutes = router({
users: privateProcedure('admin').query(({ctx}) => {
// Only admins can access this procedure.
// Context also contains the uid.
const { uid } = ctx
}),
user: privateProcedure().query(() => {
// Only authenticated users can access this procedre.
}),
})
Implementation
- Checks for a valid token in the context.
- Verifies the token using
jsonwebtoken
. - Extracts user ID and calls
authorizeUser
for role-based authorization. - Adds user ID to the context for subsequent use.
import { TRPCError } from '@trpc/server'
import { t } from './trpc'
import { Role } from './types'
import { JwtPayload, verify } from 'jsonwebtoken'
import { authorizeUser } from './util'
export const isAuthed = (...roles: Role[]) =>
t.middleware(async (opts) => {
const { token } = opts.ctx
if (!token) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Token not found.',
})
}
console.log('token', token)
let uid
try {
const user = await verify(token, process.env.NEXTAUTH_SECRET || '')
uid = (user as JwtPayload).uid
console.log('uid', uid)
} catch (error) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Invalid token.',
})
}
await authorizeUser(uid, roles)
return opts.next({ ...opts, ctx: { ...opts.ctx, uid } })
})
- authorizeUser: Compares user roles against required roles, throwing a
TRPCError
if access is not granted. - getUserRoles: Fetches user roles from the database based on user ID.
export const authorizeUser = async (
uid: string,
roles: Role[],
): Promise<void> => {
if (!roles || roles.length === 0) {
return // No specific roles required, access is granted
}
const userRoles = await getUserRoles(uid)
if (!userRoles.some((role) => roles.includes(role))) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'User does not have the required role(s).',
})
}
}
export const getUserRoles = async (uid: string): Promise<Role[]> => {
const [adminExists, managerExists] = await Promise.all([
prisma.admin.findUnique({ where: { uid } }),
prisma.manager.findUnique({ where: { uid } }),
])
const roles: Role[] = []
if (adminExists) roles.push('admin')
if (managerExists) roles.push('manager')
return roles
}
Row level permission
Implementation
- Enforces fine-grained access control at the data level.
- Checks if the requesting user’s roles or ID match allowed roles or a list of allowed user IDs.
- Throws a
TRPCError
if access is denied.
export const checkRowLevelPermission = async (
uid: string,
allowedUids: string | string[],
allowedRoles: Role[] = ['admin'],
) => {
const userRoles = await getUserRoles(uid)
if (userRoles?.some((role) => allowedRoles.includes(role))) {
return true
}
const uids =
typeof allowedUids === 'string'
? [allowedUids]
: allowedUids.filter(Boolean)
if (!uids.includes(uid)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not allowed to do this action.',
})
}
}
Usage
- Call
checkRowLevelPermission
before accessing sensitive data or performing actions to ensure authorized access. - Provide the requesting user’s ID, allowed user IDs, and optional allowed roles.
export const authRoutes = router({
user: privateProcedure()
.input(formSchemaUser)
.query(async ({ ctx, input }) => {
const user = await prisma.user.findUnique({ where: { uid: input.uid } })
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND' })
}
checkRowLevelPermission(ctx.uid, user.uid)
// [] of uids as allowedUids.
// checkRowLevelPermission(ctx.uid, [user.uid, ...managerUids])
// Optional: [] of roles
// checkRowLevelPermission(ctx.uid, [user.uid, ...managerUids]], ['admin', 'manager'])
return user
}),
})
Key Points
- Middleware provides a flexible way to intercept and handle requests before reaching procedures.
- Private procedures offer a convenient way to protect specific routes with authentication and authorization.
- Utility functions like
authorizeUser
andgetUserRoles
help organize authorization logic. - Row level permissions enable granular control over data access within procedures.
Thanks for reading!