서버 개발을 할 때 유저가 존재한다면, 가장 먼저 필요한 것이 인증과 인가라고 생각합니다.
그래서 tRPC 기반의 NestJS를 빌드하고도 가장 먼저 고민했던 부분이 Auth였습니다.
먼저 NestJS request lifecycle을 다시 생각해보면
위의 이미지처럼 middleware를 거치고 가장 먼저 만나주는 Guard, 그 중에서 Controller guards 혹은 Route guards에서 막아주는 것이 보통입니다.
코드로 예시를 써보면
@UseGuards(AuthGuard)
@Controller('user')
export class FriendController {
constructor(
private readonly friendService: FriendService,
private readonly userService: UserService,
private readonly clientService: ClientService,
) {}
@UseGuards(AuthGuard)
@Get('/me')
async getMe() {}
Controller레벨에서 아예 막아주거나, 각 api에 달아주는 방법이 있습니다.
전에썼던 블로그 글 처럼 tRPC에서는 NestJS의 Controller를 사용하지 않고 tRPC의 router를 사용해주기 때문에 다르게 Guards를 만들어줘야 합니다.
사실 JWT기반 Auth라는 것의 논리는 간단합니다.
header로 accessToken을 전달받고, 그 token을 기반으로 유저인지 아닌지 판단해주면 되는 것이기 때문입니다.
그래서 코드는 아래와 같이 구현했습니다.
@Injectable()
export class TrpcService {
constructor(private readonly authService: AuthService) {}
createContext = async (opts: CreateNextContextOptions) => {
const { req } = opts
const user = this.authService.decodeJWT(
req.headers['authorization']?.replace('Bearer ', '') || '',
)
return user
}
trpc = initTRPC.context<typeof this.createContext>().create()
auth = this.trpc.middleware(async (input) => {
const { next, ctx } = input
if (!ctx) {
throw new TRPCError({
message: 'unAuthorization error',
code: 'UNAUTHORIZED',
cause: 'jwt decode error',
})
}
const ctxAsUid = ctx as Pick<User, 'uid'>
return next({ ctx: ctxAsUid })
})
procedure = this.trpc.procedure
authProcedure = this.procedure.use(this.auth)
router = this.trpc.router
}
간단하게 설명하면 createContext가 Auth를 request를 를 받아서 jwt 를 기반으로 decode해주는 로직을 담당합니다.
그리고 authProcedure를 따로 만들어주는데 이때 middleware를 통해서 createContext에서 return해준 요소를 ctx라는 이름으로 받아오고, 이를 여기서 Guard해주는 코드를 작성해주면 됩니다.
여기서 회사의 철학에 따라서 위 처럼 error를 떤지거나, 아니면 user정보가 없는 채로 로직을 작성할 수 있게 해줄 수 있을 것 같습니다 (필요에 따라 하나의 procedure를 추가할 수도 있을 것 같기도 합니다.)
authService 자체는 간단하게 구현했습니다.
@Injectable()
export class AuthService {
constructor(
private readonly prismaService: PrismaService,
private readonly jwtService: JwtService,
) {}
async jwtSignIn(params: User) {
const user = await this.prismaService.user.findUniqueOrThrow({
where: { uid: params.uid },
})
if (!user) {
throw new HttpException('잘못된 로그인 입니다.', 405)
}
return await this.jwtService.signAsync(
{ uid: user.uid },
{
secret: JWT_SECRET,
},
)
}
decodeJWT(token: string) {
return this.jwtService.decode(token)
}
}
물론 여기서 또 다른 요소를 추가할 수 있을 것 같지만, 간단하게 구현했습니다.
여기서 위 TrpcService 코드에서 createContext의 사용에 대해서 정확히 파악이 안될 수 있는데 이는
@Injectable()
export class TrpcRouter {
constructor(
private readonly trpcService: TrpcService,
private readonly userController: UserController,
) {}
appRouter = this.trpcService.router({
user: this.userController.router,
})
async applyMiddleware(app: INestApplication) {
app.use(
`/trpc`,
trpcExpress.createExpressMiddleware({
router: this.appRouter,
createContext: this.trpcService.createContext,
onError(opts) {
const { error, type, path, input, ctx, req } = opts
console.error('Error:', error)
},
}),
)
}
}
tRPC router에서 applyMiddleware에서 createContext에서 사용되게 됩니다.
그래서 데이터의 흐름은
createContext => trpc middleware => trpc router => service 흐름으로 볼 수 있고
기존에 Guard영역을 trpc middleware가 대신해주는 것으로 대체했습니다.
쓰다보니 굳이 NestJS를 써야하나 라는생각을 했는데 DI를 잘해주기 위함과, 조금 더 프레임워크 레벨에서 코드 분리를 해줄 수 있다는 강점은 충분히 있기 때문에 NestJS를 사용하는 것에서는 큰 후회나 문제는 없을 것 같습니다.