😸

tRPCにJWT認証を追加する

2023/02/05に公開

tRPCにJWT認証を追加したのでメモとして残しておく。

ExpressでtRPCの設定を行う

tRPCにはExpress用のアダプターが含まれており、このアダプターを使用するとtRPCルーターを Expressのミドルウェアに変換することができます。

Usage with Express | tRPC

api/index.ts

import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
import { createContext } from './trpc';

const app = express();

const port = process.env.PORT || 8080;

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

app.get('/', (req, res) => {
  res.send('Hello World');
});

app.listen(port, () => {
  console.log(`server listening at http://localhost:${port}`);
});

コンテキストとミドルウェアの設定

コンテキストはすべてのtRPCプロシージャがアクセスできるデータを保持し、データベース接続や認証情報のようなものを置くのに適しています。今回はHTTP Authorization ヘッダーのトークンを検証し、そのトークンから取得したユーザー情報などをコンテキストに格納し、後続の処理に渡しています。

格納した値はqueryやmutationのときに取得することができます。

export const userRouter = createTRPCRouter({
  getUser: protectedProcedure.query(({ ctx }) => {
    return ctx.user; ← createContextで格納したuserを取得できる
  }),
});

ミドルウェアはプロシージャをラップすることで保護するルートを作成することができます。

tRPC のコンテキストとミドルウェアの解説 - Qiita

api/trpc.ts

import { inferAsyncReturnType } from '@trpc/server';
import * as trpc from '@trpc/server';

import crypto from 'crypto';
import { IncomingMessage, ServerResponse } from 'http';

import { getUserFromHeader } from './utils/jwt.util';
import { NodeHTTPCreateContextFnOptions } from '@trpc/server/dist/adapters/node-http';

export const createContext = async ({
  req,
  res,
}: NodeHTTPCreateContextFnOptions<IncomingMessage, ServerResponse>) => {
  const user = await getUserFromHeader(req.headers);

  return {
    headers: req.headers,
    user: user,
    req,
    res,
  };
};

export const protectedRoute = t.middleware(async ({ ctx, next }) => {
  const user = await getUserFromHeader(ctx.headers);
  if (!user) {
    console.log(`Unauthenticated while accesing ${ctx.req.url}`, ctx.headers);
    throw new Error(`Unauthenticated when trying to access ${ctx.req.url}`);
  }
  ctx.user = user;

  return next();
});

const t = trpc.initTRPC.context<Context>().create();
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(protectedRoute);

export type Context = inferAsyncReturnType<typeof createContext>;

getUserFromHeaderではHTTP Authorization ヘッダーのトークンを検証してPrismaからユーザー情報を取得しています。

import { IncomingMessage } from 'http';
import jwt from 'jsonwebtoken';

import { db } from '../db';
import { User } from '.prisma/client';

export const getUserFromHeader = async (
  headers: IncomingMessage['headers']
) => {
  const authHeader = headers.authorization;

  if (authHeader) {
    try {
      const user = await verifyJWTToken(authHeader.split(' ')[1]);
      return user;
    } catch (err) {
      return null;
    }
  }
  return null;
};

export const verifyJWTToken = async (token: string) => {
  try {
    const data = jwt.verify(token, process.env.JWT_TOKEN_KEY) as {
      userId: number;
      email: string;
    };
    const user = await db.user.findFirst({
      where: {
        id: data.userId,
      },
    });
    return user;
  } catch (err) {
    throw new Error('Invalid token');
  }
};

Sign upとSign inのエンドポイント作成

ルーターの設定

import { publicProcedure, createTRPCRouter } from '../trpc';

import { signinHandler, signupHandler } from '../controllers/auth.controller';
import { signinSchema, signupSchema } from '../schema/auth.schema';

export const authRouter = createTRPCRouter({
  signup: publicProcedure
    .input(signupSchema)
    .mutation(({ input }) => signupHandler(input)),
  signin: publicProcedure
    .input(signinSchema)
    .mutation(({ input }) => signinHandler(input)),
});

スキーマの設定

import z, { TypeOf } from 'zod';

export const signupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export const signinSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

export type SignupInput = TypeOf<typeof signupSchema>;
export type SigninInput = TypeOf<typeof signinSchema>;

ハンドラーの設定

import bcrypt from 'bcrypt';
import { db } from '../db';
import { TRPCError } from '@trpc/server';
import { SigninInput, SignupInput } from '../schema/auth.schema';
import { createSession } from '../utils/jwt.util';

export const signupHandler = async ({ email, password }: SigninInput) => {
  const hashedPassword = await bcrypt.hash(password, 10);
  const existingEmail = await db.user.findUnique({
    where: {
      email,
    },
  });
  if (existingEmail) {
    throw new TRPCError({
      message: 'The email is already in use',
      code: 'BAD_REQUEST',
    });
  }

  const user = await db.user.create({
    data: {
      email,
      hashedPassword,
    },
  });
  const token = await createSession(user);
  return { token };
};

export const signinHandler = async ({ email, password }: SignupInput) => {
  const user = await db.user.findUnique({
    where: {
      email: email,
    },
  });

  if (!user) throw new Error('Invalid credentials');

  const doPasswordsMatch = await bcrypt.compare(password, user.hashedPassword);
  if (!doPasswordsMatch) throw new Error('Invalid credentials');

  const token = await createSession(user);

  return { token };
};

セッションの作成

export const createSession = async (user: User) => {
  const token: string = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_TOKEN_KEY,
    {
      expiresIn: '15d',
    }
  );
  return token;
};

参考

tRPC のコンテキストとミドルウェアの解説 - Qiita

Usage with Express | tRPC

Build a tRPC CRUD API Example with Next.js 2023

Discussion