aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/middleware
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-06 17:05:21 +0900
committernsfisis <nsfisis@gmail.com>2025-12-06 17:37:04 +0900
commit811458427593a4172a2cd535cc768db375350dca (patch)
tree6c4f46c96b6f29392dc19d591e39e03c187033a1 /src/server/middleware
parent9736a8981fbd6c6defbd67517ca23904fc844629 (diff)
downloadkioku-811458427593a4172a2cd535cc768db375350dca.tar.gz
kioku-811458427593a4172a2cd535cc768db375350dca.tar.zst
kioku-811458427593a4172a2cd535cc768db375350dca.zip
feat(dev): change architecture and directory structure
Diffstat (limited to 'src/server/middleware')
-rw-r--r--src/server/middleware/auth.test.ts190
-rw-r--r--src/server/middleware/auth.ts65
-rw-r--r--src/server/middleware/error-handler.test.ts173
-rw-r--r--src/server/middleware/error-handler.ts95
-rw-r--r--src/server/middleware/index.ts2
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";