diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/index.ts | 18 | ||||
| -rw-r--r-- | src/server/routes/auth.test.ts | 36 | ||||
| -rw-r--r-- | src/server/routes/auth.ts | 240 |
3 files changed, 133 insertions, 161 deletions
diff --git a/src/server/index.ts b/src/server/index.ts index 01a489f..d157f74 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -9,15 +9,17 @@ const app = new Hono(); app.use("*", logger()); app.onError(errorHandler); -app.get("/", (c) => { - return c.json({ message: "Kioku API" }); -}); - -app.get("/api/health", (c) => { - return c.json({ status: "ok" }); -}); +// Chain routes for RPC type inference +const routes = app + .get("/", (c) => { + return c.json({ message: "Kioku API" }, 200); + }) + .get("/api/health", (c) => { + return c.json({ status: "ok" }, 200); + }) + .route("/api/auth", auth); -app.route("/api/auth", auth); +export type AppType = typeof routes; const port = Number(process.env.PORT) || 3000; console.log(`Server is running on port ${port}`); diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts index 95fd6e9..3ba504e 100644 --- a/src/server/routes/auth.test.ts +++ b/src/server/routes/auth.test.ts @@ -106,7 +106,7 @@ describe("POST /register", () => { }); }); - it("returns 422 for invalid username", async () => { + it("returns 400 for invalid username", async () => { const res = await app.request("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -116,12 +116,10 @@ describe("POST /register", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as RegisterResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); - it("returns 422 for password too short", async () => { + it("returns 400 for password too short", async () => { const res = await app.request("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -131,9 +129,7 @@ describe("POST /register", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as RegisterResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); it("returns 409 for existing username", async () => { @@ -244,7 +240,7 @@ describe("POST /login", () => { expect(body.error?.code).toBe("INVALID_CREDENTIALS"); }); - it("returns 422 for missing username", async () => { + it("returns 400 for missing username", async () => { const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -254,12 +250,10 @@ describe("POST /login", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); - it("returns 422 for missing password", async () => { + it("returns 400 for missing password", async () => { const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -269,9 +263,7 @@ describe("POST /login", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); }); @@ -400,19 +392,17 @@ describe("POST /refresh", () => { expect(body.error?.code).toBe("USER_NOT_FOUND"); }); - it("returns 422 for missing refresh token", async () => { + it("returns 400 for missing refresh token", async () => { const res = await app.request("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); - expect(res.status).toBe(422); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); - it("returns 422 for empty refresh token", async () => { + it("returns 400 for empty refresh token", async () => { const res = await app.request("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -421,8 +411,6 @@ describe("POST /refresh", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); }); diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index f0c0428..144bbae 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; +import { zValidator } from "@hono/zod-validator"; import * as argon2 from "argon2"; import { Hono } from "hono"; import { sign } from "hono/jwt"; @@ -40,159 +41,140 @@ export interface AuthDependencies { export function createAuthRouter(deps: AuthDependencies) { const { userRepo, refreshTokenRepo } = deps; - const auth = new Hono(); - auth.post("/register", async (c) => { - const body = await c.req.json(); + return new Hono() + .post("/register", zValidator("json", createUserSchema), async (c) => { + const { username, password } = c.req.valid("json"); - const parsed = createUserSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } + // Check if username already exists + const exists = await userRepo.existsByUsername(username); + if (exists) { + throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); + } - const { username, password } = parsed.data; + // Hash password with Argon2 + const passwordHash = await argon2.hash(password); - // Check if username already exists - const exists = await userRepo.existsByUsername(username); - if (exists) { - throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); - } + // Create user + const newUser = await userRepo.create({ username, passwordHash }); - // Hash password with Argon2 - const passwordHash = await argon2.hash(password); + return c.json({ user: newUser }, 201); + }) + .post("/login", zValidator("json", loginSchema), async (c) => { + const { username, password } = c.req.valid("json"); - // Create user - const newUser = await userRepo.create({ username, passwordHash }); + // Find user by username + const user = await userRepo.findByUsername(username); - return c.json({ user: newUser }, 201); - }); + if (!user) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } - auth.post("/login", async (c) => { - const body = await c.req.json(); + // Verify password + const isPasswordValid = await argon2.verify(user.passwordHash, password); + if (!isPasswordValid) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } - 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 userRepo.findByUsername(username); - - 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, + }, + getJwtSecret(), ); - } - // 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, - }, - getJwtSecret(), - ); + // Generate refresh token + const refreshToken = generateRefreshToken(); + const tokenHash = hashToken(refreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - // Generate refresh token - const refreshToken = generateRefreshToken(); - const tokenHash = hashToken(refreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + // Store refresh token in database + await refreshTokenRepo.create({ + userId: user.id, + tokenHash, + expiresAt, + }); - // Store refresh token in database - await refreshTokenRepo.create({ - userId: user.id, - tokenHash, - expiresAt, - }); + return c.json( + { + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + }, + }, + 200, + ); + }) + .post("/refresh", zValidator("json", refreshTokenSchema), async (c) => { + const { refreshToken } = c.req.valid("json"); + const tokenHash = hashToken(refreshToken); - return c.json({ - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, - }, - }); - }); + // Find valid refresh token + const storedToken = await refreshTokenRepo.findValidToken(tokenHash); - auth.post("/refresh", async (c) => { - const body = await c.req.json(); + if (!storedToken) { + throw Errors.unauthorized( + "Invalid or expired refresh token", + "INVALID_REFRESH_TOKEN", + ); + } - const parsed = refreshTokenSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } + // Get user info + const user = await userRepo.findById(storedToken.userId); - const { refreshToken } = parsed.data; - const tokenHash = hashToken(refreshToken); + if (!user) { + throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); + } - // Find valid refresh token - const storedToken = await refreshTokenRepo.findValidToken(tokenHash); + // Delete old refresh token (rotation) + await refreshTokenRepo.deleteById(storedToken.id); - if (!storedToken) { - throw Errors.unauthorized( - "Invalid or expired refresh token", - "INVALID_REFRESH_TOKEN", + // Generate new access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + getJwtSecret(), ); - } - // Get user info - const user = await userRepo.findById(storedToken.userId); + // Generate new refresh token (rotation) + const newRefreshToken = generateRefreshToken(); + const newTokenHash = hashToken(newRefreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - if (!user) { - throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); - } + await refreshTokenRepo.create({ + userId: user.id, + tokenHash: newTokenHash, + expiresAt, + }); - // Delete old refresh token (rotation) - await refreshTokenRepo.deleteById(storedToken.id); - - // Generate new access token - const now = Math.floor(Date.now() / 1000); - const accessToken = await sign( - { - sub: user.id, - iat: now, - exp: now + ACCESS_TOKEN_EXPIRES_IN, - }, - getJwtSecret(), - ); - - // Generate new refresh token (rotation) - const newRefreshToken = generateRefreshToken(); - const newTokenHash = hashToken(newRefreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - - await refreshTokenRepo.create({ - userId: user.id, - tokenHash: newTokenHash, - expiresAt, - }); - - return c.json({ - accessToken, - refreshToken: newRefreshToken, - user: { - id: user.id, - username: user.username, - }, + return c.json( + { + accessToken, + refreshToken: newRefreshToken, + user: { + id: user.id, + username: user.username, + }, + }, + 200, + ); }); - }); - - return auth; } // Default auth router with real repositories for production use |
