😸
tRPCにJWT認証を追加する
tRPCにJWT認証を追加したのでメモとして残しておく。
ExpressでtRPCの設定を行う
tRPCにはExpress用のアダプターが含まれており、このアダプターを使用するとtRPCルーターを Expressのミドルウェアに変換することができます。
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;
};
Discussion