diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-03 06:05:33 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-04 23:26:34 +0900 |
| commit | dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3 (patch) | |
| tree | 5780b2d0b0f32735477ff8f1f3308720d059ce67 | |
| parent | 20f159abf06599bc8902a445be21d4c085d82ede (diff) | |
| download | kioku-dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3.tar.gz kioku-dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3.tar.zst kioku-dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3.zip | |
refactor(auth): introduce repository pattern for database access
Add repository types and implementations to abstract database operations,
improving testability and separation of concerns. The auth routes now use
dependency injection with UserRepository and RefreshTokenRepository
interfaces, making tests simpler by mocking interfaces instead of Drizzle
query builders.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | pkgs/server/src/repositories/index.ts | 3 | ||||
| -rw-r--r-- | pkgs/server/src/repositories/refresh-token.ts | 35 | ||||
| -rw-r--r-- | pkgs/server/src/repositories/types.ts | 46 | ||||
| -rw-r--r-- | pkgs/server/src/repositories/user.ts | 55 | ||||
| -rw-r--r-- | pkgs/server/src/routes/auth.test.ts | 293 | ||||
| -rw-r--r-- | pkgs/server/src/routes/auth.ts | 351 |
7 files changed, 407 insertions, 378 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 740e9b0..63efeef 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -34,7 +34,7 @@ - [x] Add tests ### Refactoring -- [ ] Define repository types and avoid direct use of DB. +- [x] Define repository types and avoid direct use of DB. ## Phase 2: Core Features diff --git a/pkgs/server/src/repositories/index.ts b/pkgs/server/src/repositories/index.ts new file mode 100644 index 0000000..f1bcfb1 --- /dev/null +++ b/pkgs/server/src/repositories/index.ts @@ -0,0 +1,3 @@ +export { refreshTokenRepository } from "./refresh-token"; +export * from "./types"; +export { userRepository } from "./user"; diff --git a/pkgs/server/src/repositories/refresh-token.ts b/pkgs/server/src/repositories/refresh-token.ts new file mode 100644 index 0000000..82302df --- /dev/null +++ b/pkgs/server/src/repositories/refresh-token.ts @@ -0,0 +1,35 @@ +import { and, eq, gt } from "drizzle-orm"; +import { db, refreshTokens } from "../db"; +import type { RefreshTokenRepository } from "./types"; + +export const refreshTokenRepository: RefreshTokenRepository = { + async findValidToken(tokenHash) { + const [token] = await db + .select({ + id: refreshTokens.id, + userId: refreshTokens.userId, + expiresAt: refreshTokens.expiresAt, + }) + .from(refreshTokens) + .where( + and( + eq(refreshTokens.tokenHash, tokenHash), + gt(refreshTokens.expiresAt, new Date()), + ), + ) + .limit(1); + return token; + }, + + async create(data) { + await db.insert(refreshTokens).values({ + userId: data.userId, + tokenHash: data.tokenHash, + expiresAt: data.expiresAt, + }); + }, + + async deleteById(id) { + await db.delete(refreshTokens).where(eq(refreshTokens.id, id)); + }, +}; diff --git a/pkgs/server/src/repositories/types.ts b/pkgs/server/src/repositories/types.ts new file mode 100644 index 0000000..1ab4bdc --- /dev/null +++ b/pkgs/server/src/repositories/types.ts @@ -0,0 +1,46 @@ +/** + * Repository types for abstracting database operations + */ + +export interface User { + id: string; + username: string; + passwordHash: string; + createdAt: Date; + updatedAt: Date; +} + +export interface UserPublic { + id: string; + username: string; + createdAt: Date; +} + +export interface RefreshToken { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + createdAt: Date; +} + +export interface UserRepository { + findByUsername( + username: string, + ): Promise<Pick<User, "id" | "username" | "passwordHash"> | undefined>; + existsByUsername(username: string): Promise<boolean>; + create(data: { username: string; passwordHash: string }): Promise<UserPublic>; + findById(id: string): Promise<Pick<User, "id" | "username"> | undefined>; +} + +export interface RefreshTokenRepository { + findValidToken( + tokenHash: string, + ): Promise<Pick<RefreshToken, "id" | "userId" | "expiresAt"> | undefined>; + create(data: { + userId: string; + tokenHash: string; + expiresAt: Date; + }): Promise<void>; + deleteById(id: string): Promise<void>; +} diff --git a/pkgs/server/src/repositories/user.ts b/pkgs/server/src/repositories/user.ts new file mode 100644 index 0000000..7917632 --- /dev/null +++ b/pkgs/server/src/repositories/user.ts @@ -0,0 +1,55 @@ +import { eq } from "drizzle-orm"; +import { db, users } from "../db"; +import type { UserPublic, UserRepository } from "./types"; + +export const userRepository: UserRepository = { + async findByUsername(username) { + const [user] = await db + .select({ + id: users.id, + username: users.username, + passwordHash: users.passwordHash, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + return user; + }, + + async existsByUsername(username) { + const [user] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + return user !== undefined; + }, + + async create(data): Promise<UserPublic> { + const [newUser] = await db + .insert(users) + .values({ + username: data.username, + passwordHash: data.passwordHash, + }) + .returning({ + id: users.id, + username: users.username, + createdAt: users.createdAt, + }); + // Insert with returning should always return the created row + return newUser!; + }, + + async findById(id) { + const [user] = await db + .select({ + id: users.id, + username: users.username, + }) + .from(users) + .where(eq(users.id, id)) + .limit(1); + return user; + }, +}; diff --git a/pkgs/server/src/routes/auth.test.ts b/pkgs/server/src/routes/auth.test.ts index 7de04f5..34eb2b6 100644 --- a/pkgs/server/src/routes/auth.test.ts +++ b/pkgs/server/src/routes/auth.test.ts @@ -1,60 +1,12 @@ import { Hono } from "hono"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware"; -import { auth } from "./auth"; - -vi.mock("../db", () => { - const mockUsers: Array<{ - id: string; - username: string; - passwordHash: string; - createdAt: Date; - }> = []; - - return { - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => - Promise.resolve( - mockUsers.filter((u) => u.username === "existinguser"), - ), - ), - })), - })), - })), - insert: vi.fn(() => ({ - values: vi.fn((data: { username: string; passwordHash: string }) => ({ - returning: vi.fn(() => { - const newUser = { - id: "test-uuid-123", - username: data.username, - createdAt: new Date("2024-01-01T00:00:00Z"), - }; - mockUsers.push({ ...newUser, passwordHash: data.passwordHash }); - return Promise.resolve([newUser]); - }), - })), - })), - delete: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve(undefined)), - })), - }, - users: { - id: "id", - username: "username", - createdAt: "created_at", - }, - refreshTokens: { - id: "id", - userId: "user_id", - tokenHash: "token_hash", - expiresAt: "expires_at", - createdAt: "created_at", - }, - }; -}); +import type { + RefreshTokenRepository, + UserPublic, + UserRepository, +} from "../repositories"; +import { createAuthRouter } from "./auth"; vi.mock("argon2", () => ({ hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), @@ -63,6 +15,23 @@ vi.mock("argon2", () => ({ ), })); +function createMockUserRepo(): UserRepository { + return { + findByUsername: vi.fn(), + existsByUsername: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + }; +} + +function createMockRefreshTokenRepo(): RefreshTokenRepository { + return { + findValidToken: vi.fn(), + create: vi.fn(), + deleteById: vi.fn(), + }; +} + interface RegisterResponse { user?: { id: string; @@ -90,15 +59,30 @@ interface LoginResponse { describe("POST /register", () => { let app: Hono; + let mockUserRepo: ReturnType<typeof createMockUserRepo>; + let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>; beforeEach(() => { vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); app = new Hono(); app.onError(errorHandler); app.route("/api/auth", auth); }); it("creates a new user with valid credentials", async () => { + vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(false); + vi.mocked(mockUserRepo.create).mockResolvedValue({ + id: "test-uuid-123", + username: "testuser", + createdAt: new Date("2024-01-01T00:00:00Z"), + } as UserPublic); + const res = await app.request("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -115,6 +99,11 @@ describe("POST /register", () => { username: "testuser", createdAt: "2024-01-01T00:00:00.000Z", }); + expect(mockUserRepo.existsByUsername).toHaveBeenCalledWith("testuser"); + expect(mockUserRepo.create).toHaveBeenCalledWith({ + username: "testuser", + passwordHash: "hashed_securepassword12345", + }); }); it("returns 422 for invalid username", async () => { @@ -148,15 +137,7 @@ describe("POST /register", () => { }); it("returns 409 for existing username", async () => { - const { db } = await import("../db"); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: "existing-id" }]), - }), - }), - } as unknown as ReturnType<typeof db.select>); + vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(true); const res = await app.request("/api/auth/register", { method: "POST", @@ -175,34 +156,29 @@ describe("POST /register", () => { describe("POST /login", () => { let app: Hono; + let mockUserRepo: ReturnType<typeof createMockUserRepo>; + let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>; beforeEach(() => { vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); 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>); - - // Mock the insert call for refresh token - vi.mocked(db.insert).mockReturnValueOnce({ - values: vi.fn().mockResolvedValue(undefined), - } as unknown as ReturnType<typeof db.insert>); + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }); + vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); const res = await app.request("/api/auth/login", { method: "POST", @@ -223,17 +199,15 @@ describe("POST /login", () => { id: "user-uuid-123", username: "testuser", }); + expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + }); }); 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>); + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined); const res = await app.request("/api/auth/login", { method: "POST", @@ -250,20 +224,11 @@ describe("POST /login", () => { }); 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>); + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }); const res = await app.request("/api/auth/login", { method: "POST", @@ -325,55 +290,34 @@ interface RefreshResponse { describe("POST /refresh", () => { let app: Hono; + let mockUserRepo: ReturnType<typeof createMockUserRepo>; + let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>; beforeEach(() => { vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); app = new Hono(); app.onError(errorHandler); app.route("/api/auth", auth); }); it("returns new tokens for valid refresh token", async () => { - const { db } = await import("../db"); - - // Mock finding valid refresh token - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: "token-id-123", - userId: "user-uuid-123", - expiresAt: new Date(Date.now() + 86400000), // expires in 1 day - }, - ]), - }), - }), - } as unknown as ReturnType<typeof db.select>); - - // Mock finding user - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: "user-uuid-123", - username: "testuser", - }, - ]), - }), - }), - } as unknown as ReturnType<typeof db.select>); - - // Mock delete old token - vi.mocked(db.delete).mockReturnValueOnce({ - where: vi.fn().mockResolvedValue(undefined), - } as unknown as ReturnType<typeof db.delete>); - - // Mock insert new token - vi.mocked(db.insert).mockReturnValueOnce({ - values: vi.fn().mockResolvedValue(undefined), - } as unknown as ReturnType<typeof db.insert>); + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ + id: "token-id-123", + userId: "user-uuid-123", + expiresAt: new Date(Date.now() + 86400000), + }); + vi.mocked(mockUserRepo.findById).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + }); + vi.mocked(mockRefreshTokenRepo.deleteById).mockResolvedValue(undefined); + vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); const res = await app.request("/api/auth/refresh", { method: "POST", @@ -393,19 +337,18 @@ describe("POST /refresh", () => { id: "user-uuid-123", username: "testuser", }); + expect(mockRefreshTokenRepo.deleteById).toHaveBeenCalledWith( + "token-id-123", + ); + expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + }); }); it("returns 401 for invalid refresh token", async () => { - const { db } = await import("../db"); - - // Mock no token found - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - } as unknown as ReturnType<typeof db.select>); + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); const res = await app.request("/api/auth/refresh", { method: "POST", @@ -421,16 +364,7 @@ describe("POST /refresh", () => { }); it("returns 401 for expired refresh token", async () => { - const { db } = await import("../db"); - - // Mock no valid (non-expired) token found (empty result because expiry check in query) - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - } as unknown as ReturnType<typeof db.select>); + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); const res = await app.request("/api/auth/refresh", { method: "POST", @@ -446,31 +380,12 @@ describe("POST /refresh", () => { }); it("returns 401 when user not found", async () => { - const { db } = await import("../db"); - - // Mock finding valid refresh token - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: "token-id-123", - userId: "deleted-user-id", - expiresAt: new Date(Date.now() + 86400000), - }, - ]), - }), - }), - } as unknown as ReturnType<typeof db.select>); - - // Mock user not found - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - } as unknown as ReturnType<typeof db.select>); + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ + id: "token-id-123", + userId: "deleted-user-id", + expiresAt: new Date(Date.now() + 86400000), + }); + vi.mocked(mockUserRepo.findById).mockResolvedValue(undefined); const res = await app.request("/api/auth/refresh", { method: "POST", diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts index a2e6c8e..e1f7ebb 100644 --- a/pkgs/server/src/routes/auth.ts +++ b/pkgs/server/src/routes/auth.ts @@ -5,11 +5,15 @@ import { refreshTokenSchema, } from "@kioku/shared"; import * as argon2 from "argon2"; -import { and, eq, gt } from "drizzle-orm"; import { Hono } from "hono"; import { sign } from "hono/jwt"; -import { db, refreshTokens, users } from "../db"; import { Errors } from "../middleware"; +import { + type RefreshTokenRepository, + refreshTokenRepository, + type UserRepository, + userRepository, +} from "../repositories"; const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET) { @@ -26,199 +30,170 @@ function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } -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 existingUser = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.username, username)) - .limit(1); - - if (existingUser.length > 0) { - throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); - } - - // Hash password with Argon2 - const passwordHash = await argon2.hash(password); - - // Create user - const [newUser] = await db - .insert(users) - .values({ - username, - passwordHash, - }) - .returning({ - id: users.id, - username: users.username, - createdAt: users.createdAt, - }); +export interface AuthDependencies { + userRepo: UserRepository; + refreshTokenRepo: RefreshTokenRepository; +} - return c.json({ user: newUser }, 201); -}); +export function createAuthRouter(deps: AuthDependencies) { + const { userRepo, refreshTokenRepo } = deps; + const auth = new Hono(); -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, - ); - - // 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 db.insert(refreshTokens).values({ - userId: user.id, - tokenHash, - expiresAt, - }); + auth.post("/register", async (c) => { + const body = await c.req.json(); - return c.json({ - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, - }, + 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("/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 db - .select({ - id: refreshTokens.id, - userId: refreshTokens.userId, - expiresAt: refreshTokens.expiresAt, - }) - .from(refreshTokens) - .where( - and( - eq(refreshTokens.tokenHash, tokenHash), - gt(refreshTokens.expiresAt, new Date()), - ), - ) - .limit(1); - - if (!storedToken) { - throw Errors.unauthorized( - "Invalid or expired refresh token", - "INVALID_REFRESH_TOKEN", + 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); + + 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, ); - } - - // Get user info - const [user] = await db - .select({ - id: users.id, - username: users.username, - }) - .from(users) - .where(eq(users.id, storedToken.userId)) - .limit(1); - - if (!user) { - throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); - } - - // Delete old refresh token (rotation) - await db.delete(refreshTokens).where(eq(refreshTokens.id, 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, - }, - JWT_SECRET, - ); - - // Generate new refresh token (rotation) - const newRefreshToken = generateRefreshToken(); - const newTokenHash = hashToken(newRefreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - - await db.insert(refreshTokens).values({ - userId: user.id, - tokenHash: newTokenHash, - expiresAt, + + // 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, + }, + }); }); - return c.json({ - accessToken, - refreshToken: newRefreshToken, - 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", + ); + } + + // 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, + }, + JWT_SECRET, + ); + + // 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, + }, + }); }); -}); -export { auth }; + return auth; +} + +// Default auth router with real repositories for production use +export const auth = createAuthRouter({ + userRepo: userRepository, + refreshTokenRepo: refreshTokenRepository, +}); |
