aboutsummaryrefslogtreecommitdiffhomepage
path: root/pkgs/server/src/routes
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-03 05:34:44 +0900
committernsfisis <nsfisis@gmail.com>2025-12-04 23:26:27 +0900
commit742c3e55b08b37d0eb031f72a6d952bc7b7164b3 (patch)
tree119f091be511a9d870815fed9a1b98b86d638376 /pkgs/server/src/routes
parent950217ed3ca93a0aa0e964c2a8474ffc13c71912 (diff)
downloadkioku-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/src/routes')
-rw-r--r--pkgs/server/src/routes/auth.test.ts145
-rw-r--r--pkgs/server/src/routes/auth.ts66
2 files changed, 210 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 };