diff options
| -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, +}); |
