From e367c698e03c41c292c3dd5c07bad0a870c3ebc4 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 6 Dec 2025 18:11:14 +0900 Subject: feat(client): add API client with auth header support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements fetch wrapper that handles JWT authentication, automatic token refresh on 401 responses, and provides typed methods for REST operations. Includes comprehensive tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/server/routes/auth.ts | 274 ++++++++++++++++++++++------------------------ 1 file changed, 128 insertions(+), 146 deletions(-) (limited to 'src/server/routes/auth.ts') 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 -- cgit v1.2.3-70-g09d2