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:
2026-05-25 15:46:52 -04:00
parent 052e08c17b
commit 71972436b6
13 changed files with 385 additions and 17 deletions

View File

@@ -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;

View 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);
});
});

View 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>;

View File

@@ -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);