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

@@ -0,0 +1,17 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { signJWT, verifyJWT } from "./jwt";
describe("jwt", () => {
it("should sign and verify a JWT", async () => {
const payload = { sub: "user-123", role: "user" };
const token = await signJWT(payload);
const decoded = await verifyJWT<typeof payload>(token);
expect(decoded.sub).toBe("user-123");
expect(decoded.role).toBe("user");
});
it("should reject an invalid JWT", async () => {
await expect(verifyJWT("invalid.token.here")).rejects.toThrow();
});
});

View File

@@ -0,0 +1,24 @@
import { SignJWT, jwtVerify } from "jose";
function getSecret(): Uint8Array {
const secret = process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production";
return Buffer.from(secret, "utf-8");
}
export async function signJWT(
payload: Record<string, unknown>,
options?: { expiresIn?: string },
): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(options?.expiresIn ?? "7d")
.sign(getSecret());
}
export async function verifyJWT<T = Record<string, unknown>>(
token: string,
): Promise<T> {
const { payload } = await jwtVerify(token, getSecret());
return payload as T;
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { hashPassword, verifyPassword } from "./password";
describe("password", () => {
it("should hash a password", async () => {
const hash = await hashPassword("secure-password");
expect(hash).toBeTruthy();
expect(hash).not.toBe("secure-password");
});
it("should verify correct password", async () => {
const hash = await hashPassword("secure-password");
const valid = await verifyPassword("secure-password", hash);
expect(valid).toBe(true);
});
it("should reject wrong password", async () => {
const hash = await hashPassword("secure-password");
const valid = await verifyPassword("wrong-password", hash);
expect(valid).toBe(false);
});
});

View File

@@ -0,0 +1,14 @@
import bcrypt from "bcryptjs";
const SALT_ROUNDS = 10;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string,
): Promise<boolean> {
return bcrypt.compare(password, hash);
}

View File

@@ -0,0 +1,35 @@
import { db } from "~/server/db";
import { sessions, users } from "~/server/db/schema/auth";
import { eq, and, gt } from "drizzle-orm";
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
export async function createSession(
userId: string,
): Promise<typeof sessions.$inferSelect> {
const token = crypto.randomUUID();
const expires = new Date(Date.now() + SEVEN_DAYS_MS);
const [session] = await db
.insert(sessions)
.values({ userId, sessionToken: token, expires })
.returning();
return session;
}
export async function validateSession(
sessionToken: string,
): Promise<{ session: typeof sessions.$inferSelect; user: typeof users.$inferSelect } | null> {
const [result] = await db
.select({ session: sessions, user: users })
.from(sessions)
.where(
and(
eq(sessions.sessionToken, sessionToken),
gt(sessions.expires, new Date()),
),
)
.innerJoin(users, eq(sessions.userId, users.id))
.limit(1);
return result ?? null;
}