diff options
Diffstat (limited to 'src/server/middleware')
| -rw-r--r-- | src/server/middleware/auth.test.ts | 190 | ||||
| -rw-r--r-- | src/server/middleware/auth.ts | 65 | ||||
| -rw-r--r-- | src/server/middleware/error-handler.test.ts | 173 | ||||
| -rw-r--r-- | src/server/middleware/error-handler.ts | 95 | ||||
| -rw-r--r-- | src/server/middleware/index.ts | 2 |
5 files changed, 525 insertions, 0 deletions
diff --git a/src/server/middleware/auth.test.ts b/src/server/middleware/auth.test.ts new file mode 100644 index 0000000..a8b7f3d --- /dev/null +++ b/src/server/middleware/auth.test.ts @@ -0,0 +1,190 @@ +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { beforeEach, describe, expect, it } from "vitest"; +import { authMiddleware, getAuthUser } from "./auth.js"; +import { errorHandler } from "./error-handler.js"; + +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/src/server/middleware/auth.ts b/src/server/middleware/auth.ts new file mode 100644 index 0000000..51b4d9d --- /dev/null +++ b/src/server/middleware/auth.ts @@ -0,0 +1,65 @@ +import type { Context, Next } from "hono"; +import { verify } from "hono/jwt"; +import { Errors } from "./error-handler.js"; + +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/src/server/middleware/error-handler.test.ts b/src/server/middleware/error-handler.test.ts new file mode 100644 index 0000000..d4be84f --- /dev/null +++ b/src/server/middleware/error-handler.test.ts @@ -0,0 +1,173 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; +import { AppError, Errors, errorHandler } from "./error-handler.js"; + +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/src/server/middleware/error-handler.ts b/src/server/middleware/error-handler.ts new file mode 100644 index 0000000..7b92940 --- /dev/null +++ b/src/server/middleware/error-handler.ts @@ -0,0 +1,95 @@ +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/src/server/middleware/index.ts b/src/server/middleware/index.ts new file mode 100644 index 0000000..e894a42 --- /dev/null +++ b/src/server/middleware/index.ts @@ -0,0 +1,2 @@ +export { type AuthUser, authMiddleware, getAuthUser } from "./auth.js"; +export { AppError, Errors, errorHandler } from "./error-handler.js"; |
