aboutsummaryrefslogtreecommitdiffhomepage
path: root/pkgs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-03 05:54:50 +0900
committernsfisis <nsfisis@gmail.com>2025-12-04 23:26:31 +0900
commit4d30f46d4691c9aead411b893b1ab279b05d439c (patch)
treeb5c05015a4b367daf7d9d885f67a7fb1cbc0d0bf /pkgs
parent0763153865e2157e0d06c946993dd8b235b06c83 (diff)
downloadkioku-4d30f46d4691c9aead411b893b1ab279b05d439c.tar.gz
kioku-4d30f46d4691c9aead411b893b1ab279b05d439c.tar.zst
kioku-4d30f46d4691c9aead411b893b1ab279b05d439c.zip
feat(auth): add auth middleware for JWT validation
Add middleware that validates JWT tokens from Authorization header and sets authenticated user in request context. Includes helper function getAuthUser() to retrieve user from context with proper error handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'pkgs')
-rw-r--r--pkgs/server/src/middleware/auth.test.ts190
-rw-r--r--pkgs/server/src/middleware/auth.ts65
-rw-r--r--pkgs/server/src/middleware/index.ts1
3 files changed, 256 insertions, 0 deletions
diff --git a/pkgs/server/src/middleware/auth.test.ts b/pkgs/server/src/middleware/auth.test.ts
new file mode 100644
index 0000000..8c4286b
--- /dev/null
+++ b/pkgs/server/src/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";
+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
new file mode 100644
index 0000000..c295834
--- /dev/null
+++ b/pkgs/server/src/middleware/auth.ts
@@ -0,0 +1,65 @@
+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/index.ts b/pkgs/server/src/middleware/index.ts
index bd3d4dc..57de4dd 100644
--- a/pkgs/server/src/middleware/index.ts
+++ b/pkgs/server/src/middleware/index.ts
@@ -1 +1,2 @@
+export { type AuthUser, authMiddleware, getAuthUser } from "./auth";
export { AppError, Errors, errorHandler } from "./error-handler";