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:
17
web/src/server/auth/jwt.test.ts
Normal file
17
web/src/server/auth/jwt.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
web/src/server/auth/jwt.ts
Normal file
24
web/src/server/auth/jwt.ts
Normal 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;
|
||||
}
|
||||
22
web/src/server/auth/password.test.ts
Normal file
22
web/src/server/auth/password.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
web/src/server/auth/password.ts
Normal file
14
web/src/server/auth/password.ts
Normal 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);
|
||||
}
|
||||
35
web/src/server/auth/session.ts
Normal file
35
web/src/server/auth/session.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user