feat: add tRPC auth context, middleware, and protected procedures
- Install jose (JWT) and bcryptjs (password hashing) dependencies - Create auth utilities: JWT sign/verify, password hash/verify, session management - Create createTRPCContext that extracts auth from session cookie, Bearer JWT, or x-api-key - Add publicProcedure, protectedProcedure, adminProcedure, rateLimitedProcedure with middleware - Wire context builder into SolidStart tRPC API handler - Update tRPC client to inject auth tokens and handle 401 redirects - Add unit tests for JWT, password, context builder, and middleware
This commit is contained in:
@@ -2,7 +2,7 @@ import { exampleRouter } from "./routers/example";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
example: exampleRouter
|
||||
example: exampleRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
92
web/src/server/api/trpc.test.ts
Normal file
92
web/src/server/api/trpc.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {},
|
||||
}));
|
||||
|
||||
vi.mock("~/server/auth/session", () => ({
|
||||
validateSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/server/auth/jwt", () => ({
|
||||
verifyJWT: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("createTRPCContext", () => {
|
||||
it("should export createTRPCContext function", async () => {
|
||||
const mod = await import("./trpc");
|
||||
expect(mod.createTRPCContext).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it("should return anonymous context for unauthenticated requests", async () => {
|
||||
const { createTRPCContext } = await import("./trpc");
|
||||
const ctx = await createTRPCContext({
|
||||
req: new Request("http://localhost:3000/api/trpc"),
|
||||
});
|
||||
expect(ctx.user).toBeNull();
|
||||
expect(ctx.apiKey).toBeNull();
|
||||
expect(ctx.db).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tRPC middleware", () => {
|
||||
type TestCtx = { user?: { id: string; role: string }; db: object };
|
||||
|
||||
it("publicProcedure should allow unauthenticated access", async () => {
|
||||
const { publicProcedure } = await import("./utils");
|
||||
const t = initTRPC.context<TestCtx>().create();
|
||||
const testRouter = t.router({
|
||||
test: publicProcedure.query(() => "ok"),
|
||||
});
|
||||
const caller = t.createCallerFactory(testRouter);
|
||||
const result = await caller({ db: {} }).test();
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("protectedProcedure should reject unauthenticated requests", async () => {
|
||||
const { protectedProcedure } = await import("./utils");
|
||||
const t = initTRPC.context<TestCtx>().create();
|
||||
const testRouter = t.router({
|
||||
test: protectedProcedure.query(() => "ok"),
|
||||
});
|
||||
const caller = t.createCallerFactory(testRouter);
|
||||
await expect(caller({ db: {} }).test()).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("protectedProcedure should allow authenticated requests", async () => {
|
||||
const { protectedProcedure } = await import("./utils");
|
||||
const t = initTRPC.context<TestCtx>().create();
|
||||
const testRouter = t.router({
|
||||
test: protectedProcedure.query(({ ctx }) => ctx.user?.id),
|
||||
});
|
||||
const caller = t.createCallerFactory(testRouter);
|
||||
const result = await caller({
|
||||
db: {},
|
||||
user: { id: "user-1", role: "user" },
|
||||
}).test();
|
||||
expect(result).toBe("user-1");
|
||||
});
|
||||
|
||||
it("adminProcedure should reject non-admin users with FORBIDDEN", async () => {
|
||||
const { adminProcedure } = await import("./utils");
|
||||
const t = initTRPC.context<TestCtx>().create();
|
||||
const testRouter = t.router({
|
||||
test: adminProcedure.query(() => "ok"),
|
||||
});
|
||||
const caller = t.createCallerFactory(testRouter);
|
||||
await expect(
|
||||
caller({ db: {}, user: { id: "user-1", role: "user" } }).test(),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("adminProcedure should reject unauthenticated with UNAUTHORIZED", async () => {
|
||||
const { adminProcedure } = await import("./utils");
|
||||
const t = initTRPC.context<TestCtx>().create();
|
||||
const testRouter = t.router({
|
||||
test: adminProcedure.query(() => "ok"),
|
||||
});
|
||||
const caller = t.createCallerFactory(testRouter);
|
||||
await expect(caller({ db: {} }).test()).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
81
web/src/server/api/trpc.ts
Normal file
81
web/src/server/api/trpc.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { inferAsyncReturnType } from "@trpc/server";
|
||||
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||
import { db } from "~/server/db";
|
||||
import { verifyJWT } from "~/server/auth/jwt";
|
||||
import { validateSession } from "~/server/auth/session";
|
||||
import { users } from "~/server/db/schema/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type CreateTRPCContextOptions = {
|
||||
req: Request;
|
||||
resHeaders?: Headers;
|
||||
};
|
||||
|
||||
function parseCookies(req: Request): Record<string, string> {
|
||||
const cookieHeader = req.headers.get("cookie") ?? "";
|
||||
const cookies: Record<string, string> = {};
|
||||
for (const cookie of cookieHeader.split(";")) {
|
||||
const trimmed = cookie.trim();
|
||||
if (!trimmed) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) {
|
||||
cookies[trimmed] = "";
|
||||
} else {
|
||||
cookies[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
export async function createTRPCContext(
|
||||
opts: CreateTRPCContextOptions,
|
||||
): Promise<{
|
||||
db: typeof db;
|
||||
user: typeof users.$inferSelect | null;
|
||||
apiKey: string | null;
|
||||
}> {
|
||||
const { req } = opts;
|
||||
let userId: string | null = null;
|
||||
let apiKey: string | null = null;
|
||||
|
||||
const cookies = parseCookies(req);
|
||||
const sessionToken = cookies["session_token"];
|
||||
|
||||
if (sessionToken) {
|
||||
const result = await validateSession(sessionToken);
|
||||
if (result) {
|
||||
userId = result.user.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = await verifyJWT<{ sub?: string }>(token);
|
||||
userId = payload.sub ?? null;
|
||||
} catch {
|
||||
// Invalid token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
apiKey = req.headers.get("x-api-key") ?? null;
|
||||
}
|
||||
|
||||
let user: typeof users.$inferSelect | null = null;
|
||||
if (userId) {
|
||||
const [found] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
user = found ?? null;
|
||||
}
|
||||
|
||||
return { db, user, apiKey };
|
||||
}
|
||||
|
||||
export type TRPCContext = inferAsyncReturnType<typeof createTRPCContext>;
|
||||
@@ -1,6 +1,59 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { TRPCContext } from "./trpc";
|
||||
|
||||
export const t = initTRPC.create();
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
export const createTRPCRouter = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({
|
||||
ctx: { user: ctx.user },
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||
|
||||
const isAdmin = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if ((ctx.user.role as string) !== "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
return next({
|
||||
ctx: { user: ctx.user },
|
||||
});
|
||||
});
|
||||
|
||||
export const adminProcedure = t.procedure.use(isAdmin);
|
||||
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
const isRateLimited = t.middleware(({ ctx, next }) => {
|
||||
const identifier = ctx.user?.id ?? ctx.apiKey ?? "anonymous";
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(identifier);
|
||||
const limit = 100;
|
||||
const windowMs = 60_000;
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(identifier, { count: 1, resetAt: now + windowMs });
|
||||
return next();
|
||||
}
|
||||
|
||||
if (entry.count >= limit) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Rate limit exceeded",
|
||||
});
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return next();
|
||||
});
|
||||
|
||||
export const rateLimitedProcedure = t.procedure.use(isRateLimited);
|
||||
|
||||
Reference in New Issue
Block a user