From dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 3 Dec 2025 06:05:33 +0900 Subject: refactor(auth): introduce repository pattern for database access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkgs/server/src/repositories/index.ts | 3 + pkgs/server/src/repositories/refresh-token.ts | 35 +++ pkgs/server/src/repositories/types.ts | 46 ++++ pkgs/server/src/repositories/user.ts | 55 ++++ pkgs/server/src/routes/auth.test.ts | 293 ++++++++------------- pkgs/server/src/routes/auth.ts | 351 ++++++++++++-------------- 6 files changed, 406 insertions(+), 377 deletions(-) create mode 100644 pkgs/server/src/repositories/index.ts create mode 100644 pkgs/server/src/repositories/refresh-token.ts create mode 100644 pkgs/server/src/repositories/types.ts create mode 100644 pkgs/server/src/repositories/user.ts (limited to 'pkgs/server') 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 | undefined>; + existsByUsername(username: string): Promise; + create(data: { username: string; passwordHash: string }): Promise; + findById(id: string): Promise | undefined>; +} + +export interface RefreshTokenRepository { + findValidToken( + tokenHash: string, + ): Promise | undefined>; + create(data: { + userId: string; + tokenHash: string; + expiresAt: Date; + }): Promise; + deleteById(id: string): Promise; +} 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 { + 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; + let mockRefreshTokenRepo: ReturnType; 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); + 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; + let mockRefreshTokenRepo: ReturnType; 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); - - // Mock the insert call for refresh token - vi.mocked(db.insert).mockReturnValueOnce({ - values: vi.fn().mockResolvedValue(undefined), - } as unknown as ReturnType); + 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); + 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); + 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; + let mockRefreshTokenRepo: ReturnType; 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); - - // 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); - - // Mock delete old token - vi.mocked(db.delete).mockReturnValueOnce({ - where: vi.fn().mockResolvedValue(undefined), - } as unknown as ReturnType); - - // Mock insert new token - vi.mocked(db.insert).mockReturnValueOnce({ - values: vi.fn().mockResolvedValue(undefined), - } as unknown as ReturnType); + 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); + 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); + 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); - - // 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); + 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, +}); -- cgit v1.2.3-70-g09d2