diff options
Diffstat (limited to 'pkgs/server/src/middleware')
| -rw-r--r-- | pkgs/server/src/middleware/auth.test.ts | 190 | ||||
| -rw-r--r-- | pkgs/server/src/middleware/auth.ts | 65 | ||||
| -rw-r--r-- | pkgs/server/src/middleware/error-handler.test.ts | 173 | ||||
| -rw-r--r-- | pkgs/server/src/middleware/error-handler.ts | 95 | ||||
| -rw-r--r-- | pkgs/server/src/middleware/index.ts | 2 |
5 files changed, 0 insertions, 525 deletions
diff --git a/pkgs/server/src/middleware/auth.test.ts b/pkgs/server/src/middleware/auth.test.ts deleted file mode 100644 index 8c4286b..0000000 --- a/pkgs/server/src/middleware/auth.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Hono } from "hono"; -import { sign } from "hono/jwt"; -import { beforeEach, describe, expect, it } from "vitest"; -import { authMiddleware, getAuthUser } from "./auth"; -import { errorHandler } from "./error-handler"; - -const JWT_SECRET = process.env.JWT_SECRET || "test-secret"; - -interface ErrorResponse { - error: { - code: string; - message: string; - }; -} - -interface SuccessResponse { - userId: string; -} - -describe("authMiddleware", () => { - let app: Hono; - - beforeEach(() => { - app = new Hono(); - app.onError(errorHandler); - app.use("/protected/*", authMiddleware); - app.get("/protected/resource", (c) => { - const user = getAuthUser(c); - return c.json({ userId: user.id }); - }); - }); - - it("allows access with valid token", async () => { - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "user-123", - iat: now, - exp: now + 3600, - }, - JWT_SECRET, - ); - - const res = await app.request("/protected/resource", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as SuccessResponse; - expect(body.userId).toBe("user-123"); - }); - - it("returns 401 when Authorization header is missing", async () => { - const res = await app.request("/protected/resource"); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("MISSING_AUTH"); - }); - - it("returns 401 for invalid Authorization format", async () => { - const res = await app.request("/protected/resource", { - headers: { - Authorization: "Basic sometoken", - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); - }); - - it("returns 401 for empty Bearer token", async () => { - const res = await app.request("/protected/resource", { - headers: { - Authorization: "Bearer", - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); - }); - - it("returns 401 for expired token", async () => { - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "user-123", - iat: now - 7200, - exp: now - 3600, // expired 1 hour ago - }, - JWT_SECRET, - ); - - const res = await app.request("/protected/resource", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_TOKEN"); - }); - - it("returns 401 for invalid token", async () => { - const res = await app.request("/protected/resource", { - headers: { - Authorization: "Bearer invalid.token.here", - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_TOKEN"); - }); - - it("returns 401 for token signed with wrong secret", async () => { - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "user-123", - iat: now, - exp: now + 3600, - }, - "wrong-secret", - ); - - const res = await app.request("/protected/resource", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_TOKEN"); - }); -}); - -describe("getAuthUser", () => { - it("returns user from context when authenticated", async () => { - const app = new Hono(); - app.onError(errorHandler); - app.use("/test", authMiddleware); - app.get("/test", (c) => { - const user = getAuthUser(c); - return c.json({ id: user.id }); - }); - - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "test-user-456", - iat: now, - exp: now + 3600, - }, - JWT_SECRET, - ); - - const res = await app.request("/test", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { id: string }; - expect(body.id).toBe("test-user-456"); - }); - - it("throws when user is not in context", async () => { - const app = new Hono(); - app.onError(errorHandler); - // Note: no authMiddleware applied - app.get("/unprotected", (c) => { - const user = getAuthUser(c); - return c.json({ id: user.id }); - }); - - const res = await app.request("/unprotected"); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("NOT_AUTHENTICATED"); - }); -}); diff --git a/pkgs/server/src/middleware/auth.ts b/pkgs/server/src/middleware/auth.ts deleted file mode 100644 index c295834..0000000 --- a/pkgs/server/src/middleware/auth.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Context, Next } from "hono"; -import { verify } from "hono/jwt"; -import { Errors } from "./error-handler"; - -const JWT_SECRET = process.env.JWT_SECRET; -if (!JWT_SECRET) { - throw new Error("JWT_SECRET environment variable is required"); -} - -export interface AuthUser { - id: string; -} - -interface JWTPayload { - sub: string; - iat: number; - exp: number; -} - -/** - * Auth middleware that validates JWT tokens from Authorization header - * Sets the authenticated user in context variables - */ -export async function authMiddleware(c: Context, next: Next) { - const authHeader = c.req.header("Authorization"); - - if (!authHeader) { - throw Errors.unauthorized("Missing Authorization header", "MISSING_AUTH"); - } - - if (!authHeader.startsWith("Bearer ")) { - throw Errors.unauthorized( - "Invalid Authorization header format", - "INVALID_AUTH_FORMAT", - ); - } - - const token = authHeader.slice(7); - - try { - const payload = (await verify(token, JWT_SECRET)) as unknown as JWTPayload; - - const user: AuthUser = { - id: payload.sub, - }; - - c.set("user", user); - - await next(); - } catch { - throw Errors.unauthorized("Invalid or expired token", "INVALID_TOKEN"); - } -} - -/** - * Helper function to get the authenticated user from context - * Throws if user is not authenticated - */ -export function getAuthUser(c: Context): AuthUser { - const user = c.get("user") as AuthUser | undefined; - if (!user) { - throw Errors.unauthorized("Not authenticated", "NOT_AUTHENTICATED"); - } - return user; -} diff --git a/pkgs/server/src/middleware/error-handler.test.ts b/pkgs/server/src/middleware/error-handler.test.ts deleted file mode 100644 index 21d6fc1..0000000 --- a/pkgs/server/src/middleware/error-handler.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Hono } from "hono"; -import { describe, expect, it } from "vitest"; -import { AppError, Errors, errorHandler } from "./error-handler"; - -function createTestApp() { - const app = new Hono(); - app.onError(errorHandler); - return app; -} - -describe("errorHandler", () => { - describe("AppError handling", () => { - it("returns correct status and message for AppError", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw new AppError("Custom error", 400, "CUSTOM_ERROR"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: { - message: "Custom error", - code: "CUSTOM_ERROR", - }, - }); - }); - - it("uses default values for AppError", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw new AppError("Something went wrong"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ - error: { - message: "Something went wrong", - code: "INTERNAL_ERROR", - }, - }); - }); - }); - - describe("Errors factory functions", () => { - it("handles badRequest error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.badRequest("Invalid input"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: { - message: "Invalid input", - code: "BAD_REQUEST", - }, - }); - }); - - it("handles unauthorized error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.unauthorized(); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ - error: { - message: "Unauthorized", - code: "UNAUTHORIZED", - }, - }); - }); - - it("handles forbidden error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.forbidden("Access denied"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(403); - expect(await res.json()).toEqual({ - error: { - message: "Access denied", - code: "FORBIDDEN", - }, - }); - }); - - it("handles notFound error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.notFound("Resource not found"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ - error: { - message: "Resource not found", - code: "NOT_FOUND", - }, - }); - }); - - it("handles conflict error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.conflict("Already exists"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(409); - expect(await res.json()).toEqual({ - error: { - message: "Already exists", - code: "CONFLICT", - }, - }); - }); - - it("handles validationError", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.validationError("Invalid data"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(422); - expect(await res.json()).toEqual({ - error: { - message: "Invalid data", - code: "VALIDATION_ERROR", - }, - }); - }); - - it("handles internal error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.internal("Database connection failed"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ - error: { - message: "Database connection failed", - code: "INTERNAL_ERROR", - }, - }); - }); - }); - - describe("unknown error handling", () => { - it("handles generic Error with 500 status", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw new Error("Unexpected error"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("INTERNAL_ERROR"); - }); - }); -}); diff --git a/pkgs/server/src/middleware/error-handler.ts b/pkgs/server/src/middleware/error-handler.ts deleted file mode 100644 index 7b92940..0000000 --- a/pkgs/server/src/middleware/error-handler.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Context } from "hono"; -import { HTTPException } from "hono/http-exception"; -import type { ContentfulStatusCode } from "hono/utils/http-status"; - -/** - * Application-specific error with status code and optional details - */ -export class AppError extends Error { - readonly statusCode: ContentfulStatusCode; - readonly code: string; - - constructor( - message: string, - statusCode: ContentfulStatusCode = 500, - code = "INTERNAL_ERROR", - ) { - super(message); - this.name = "AppError"; - this.statusCode = statusCode; - this.code = code; - } -} - -/** - * Common error factory functions - */ -export const Errors = { - badRequest: (message = "Bad request", code = "BAD_REQUEST") => - new AppError(message, 400, code), - - unauthorized: (message = "Unauthorized", code = "UNAUTHORIZED") => - new AppError(message, 401, code), - - forbidden: (message = "Forbidden", code = "FORBIDDEN") => - new AppError(message, 403, code), - - notFound: (message = "Not found", code = "NOT_FOUND") => - new AppError(message, 404, code), - - conflict: (message = "Conflict", code = "CONFLICT") => - new AppError(message, 409, code), - - validationError: (message = "Validation failed", code = "VALIDATION_ERROR") => - new AppError(message, 422, code), - - internal: (message = "Internal server error", code = "INTERNAL_ERROR") => - new AppError(message, 500, code), -}; - -interface ErrorResponse { - error: { - message: string; - code: string; - }; -} - -/** - * Global error handler middleware for Hono - */ -export function errorHandler(err: Error, c: Context): Response { - // Handle AppError - if (err instanceof AppError) { - const response: ErrorResponse = { - error: { - message: err.message, - code: err.code, - }, - }; - return c.json(response, err.statusCode); - } - - // Handle Hono's HTTPException - if (err instanceof HTTPException) { - const response: ErrorResponse = { - error: { - message: err.message, - code: "HTTP_ERROR", - }, - }; - return c.json(response, err.status as ContentfulStatusCode); - } - - // Handle unknown errors - console.error("Unhandled error:", err); - const response: ErrorResponse = { - error: { - message: - process.env.NODE_ENV === "production" - ? "Internal server error" - : err.message, - code: "INTERNAL_ERROR", - }, - }; - return c.json(response, 500); -} diff --git a/pkgs/server/src/middleware/index.ts b/pkgs/server/src/middleware/index.ts deleted file mode 100644 index 57de4dd..0000000 --- a/pkgs/server/src/middleware/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { type AuthUser, authMiddleware, getAuthUser } from "./auth"; -export { AppError, Errors, errorHandler } from "./error-handler"; |
