diff options
Diffstat (limited to 'src/server/routes')
| -rw-r--r-- | src/server/routes/auth.test.ts | 36 | ||||
| -rw-r--r-- | src/server/routes/auth.ts | 274 |
2 files changed, 140 insertions, 170 deletions
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(); - - const parsed = createUserSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - - const { username, password } = parsed.data; - - // Check if username already exists - const exists = await userRepo.existsByUsername(username); - if (exists) { - throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); - } - - // Hash password with Argon2 - const passwordHash = await argon2.hash(password); - - // Create user - const newUser = await userRepo.create({ username, passwordHash }); - - 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 userRepo.findByUsername(username); + return new Hono() + .post("/register", zValidator("json", createUserSchema), async (c) => { + const { username, password } = c.req.valid("json"); + + // Check if username already exists + const exists = await userRepo.existsByUsername(username); + if (exists) { + throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); + } + + // Hash password with Argon2 + const passwordHash = await argon2.hash(password); + + // Create user + const newUser = await userRepo.create({ username, passwordHash }); + + return c.json({ user: newUser }, 201); + }) + .post("/login", zValidator("json", loginSchema), async (c) => { + const { username, password } = c.req.valid("json"); + + // 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(), + ); - if (!user) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", + // 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, + }); + + return c.json( + { + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + }, + }, + 200, ); - } - - // Verify password - const isPasswordValid = await argon2.verify(user.passwordHash, password); - if (!isPasswordValid) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", + }) + .post("/refresh", zValidator("json", refreshTokenSchema), async (c) => { + const { refreshToken } = c.req.valid("json"); + const tokenHash = hashToken(refreshToken); + + // Find valid refresh token + const storedToken = await refreshTokenRepo.findValidToken(tokenHash); + + if (!storedToken) { + throw Errors.unauthorized( + "Invalid or expired refresh token", + "INVALID_REFRESH_TOKEN", + ); + } + + // Get user info + const user = await userRepo.findById(storedToken.userId); + + if (!user) { + throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); + } + + // 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 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); - - // 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, - }, - }); - }); - - auth.post("/refresh", async (c) => { - const body = await c.req.json(); - - const parsed = refreshTokenSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - const { refreshToken } = parsed.data; - const tokenHash = hashToken(refreshToken); - - // Find valid refresh token - const storedToken = await refreshTokenRepo.findValidToken(tokenHash); - - if (!storedToken) { - throw Errors.unauthorized( - "Invalid or expired refresh token", - "INVALID_REFRESH_TOKEN", + // 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, + }, + }, + 200, ); - } - - // Get user info - const user = await userRepo.findById(storedToken.userId); - - if (!user) { - throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); - } - - // 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 auth; } // Default auth router with real repositories for production use |
