diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-03 05:34:44 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-04 23:26:27 +0900 |
| commit | 742c3e55b08b37d0eb031f72a6d952bc7b7164b3 (patch) | |
| tree | 119f091be511a9d870815fed9a1b98b86d638376 /pkgs/server | |
| parent | 950217ed3ca93a0aa0e964c2a8474ffc13c71912 (diff) | |
| download | kioku-742c3e55b08b37d0eb031f72a6d952bc7b7164b3.tar.gz kioku-742c3e55b08b37d0eb031f72a6d952bc7b7164b3.tar.zst kioku-742c3e55b08b37d0eb031f72a6d952bc7b7164b3.zip | |
feat(auth): add login endpoint with JWT
Implement POST /api/auth/login endpoint that validates credentials
and returns a JWT access token on successful authentication.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'pkgs/server')
| -rw-r--r-- | pkgs/server/src/routes/auth.test.ts | 145 | ||||
| -rw-r--r-- | pkgs/server/src/routes/auth.ts | 66 | ||||
| -rw-r--r-- | pkgs/server/vitest.config.ts | 3 |
3 files changed, 213 insertions, 1 deletions
diff --git a/pkgs/server/src/routes/auth.test.ts b/pkgs/server/src/routes/auth.test.ts index 2d60636..1dfba46 100644 --- a/pkgs/server/src/routes/auth.test.ts +++ b/pkgs/server/src/routes/auth.test.ts @@ -48,6 +48,9 @@ vi.mock("../db", () => { vi.mock("argon2", () => ({ hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), + verify: vi.fn((hash: string, password: string) => + Promise.resolve(hash === `hashed_${password}`), + ), })); interface RegisterResponse { @@ -62,6 +65,18 @@ interface RegisterResponse { }; } +interface LoginResponse { + accessToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + describe("POST /register", () => { let app: Hono; @@ -146,3 +161,133 @@ describe("POST /register", () => { expect(body.error?.code).toBe("USERNAME_EXISTS"); }); }); + +describe("POST /login", () => { + let app: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("returns access token for valid credentials", async () => { + const { db } = await import("../db"); + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }, + ]), + }), + }), + } as unknown as ReturnType<typeof db.select>); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "correctpassword", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as LoginResponse; + expect(body.accessToken).toBeDefined(); + expect(typeof body.accessToken).toBe("string"); + expect(body.user).toEqual({ + id: "user-uuid-123", + username: "testuser", + }); + }); + + it("returns 401 for non-existent user", async () => { + const { db } = await import("../db"); + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as unknown as ReturnType<typeof db.select>); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "nonexistent", + password: "anypassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 401 for incorrect password", async () => { + const { db } = await import("../db"); + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }, + ]), + }), + }), + } as unknown as ReturnType<typeof db.select>); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "wrongpassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 422 for missing username", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "", + password: "somepassword", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for missing password", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); +}); diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts index 3906d65..ed497b1 100644 --- a/pkgs/server/src/routes/auth.ts +++ b/pkgs/server/src/routes/auth.ts @@ -1,10 +1,17 @@ -import { createUserSchema } from "@kioku/shared"; +import { createUserSchema, loginSchema } from "@kioku/shared"; import * as argon2 from "argon2"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; +import { sign } from "hono/jwt"; import { db, users } from "../db"; import { Errors } from "../middleware"; +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); +} +const ACCESS_TOKEN_EXPIRES_IN = 60 * 15; // 15 minutes + const auth = new Hono(); auth.post("/register", async (c) => { @@ -47,4 +54,61 @@ auth.post("/register", async (c) => { return c.json({ user: newUser }, 201); }); +auth.post("/login", async (c) => { + const body = await c.req.json(); + + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Find user by username + const [user] = await db + .select({ + id: users.id, + username: users.username, + passwordHash: users.passwordHash, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (!user) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Verify password + const isPasswordValid = await argon2.verify(user.passwordHash, password); + if (!isPasswordValid) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Generate JWT access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, + ); + + return c.json({ + accessToken, + user: { + id: user.id, + username: user.username, + }, + }); +}); + export { auth }; diff --git a/pkgs/server/vitest.config.ts b/pkgs/server/vitest.config.ts index 076c92f..af9649f 100644 --- a/pkgs/server/vitest.config.ts +++ b/pkgs/server/vitest.config.ts @@ -3,5 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, + env: { + JWT_SECRET: "test-secret-key", + }, }, }); |
