diff options
Diffstat (limited to 'src/server/routes')
| -rw-r--r-- | src/server/routes/auth.test.ts | 428 | ||||
| -rw-r--r-- | src/server/routes/auth.ts | 199 | ||||
| -rw-r--r-- | src/server/routes/index.ts | 1 |
3 files changed, 628 insertions, 0 deletions
diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts new file mode 100644 index 0000000..95fd6e9 --- /dev/null +++ b/src/server/routes/auth.test.ts @@ -0,0 +1,428 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import type { + RefreshTokenRepository, + UserPublic, + UserRepository, +} from "../repositories/index.js"; +import { createAuthRouter } from "./auth.js"; + +vi.mock("argon2", () => ({ + hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), + verify: vi.fn((hash: string, password: string) => + Promise.resolve(hash === `hashed_${password}`), + ), +})); + +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; + username: string; + createdAt: string; + }; + error?: { + code: string; + message: string; + }; +} + +interface LoginResponse { + accessToken?: string; + refreshToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + +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" }, + body: JSON.stringify({ + username: "testuser", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as RegisterResponse; + expect(body.user).toEqual({ + id: "test-uuid-123", + 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 () => { + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for password too short", async () => { + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "tooshort123456", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 409 for existing username", async () => { + vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(true); + + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "existinguser", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("USERNAME_EXISTS"); + }); +}); + +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 () => { + 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", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "correctpassword", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as LoginResponse; + expect(body.accessToken).toBeDefined(); + expect(typeof body.accessToken).toBe("string"); + expect(body.refreshToken).toBeDefined(); + expect(typeof body.refreshToken).toBe("string"); + expect(body.user).toEqual({ + 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 () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "nonexistent", + password: "anypassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 401 for incorrect password", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "wrongpassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 422 for missing username", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "", + password: "somepassword", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for missing password", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); +}); + +interface RefreshResponse { + accessToken?: string; + refreshToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + +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 () => { + 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", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "valid-refresh-token-hex", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as RefreshResponse; + expect(body.accessToken).toBeDefined(); + expect(typeof body.accessToken).toBe("string"); + expect(body.refreshToken).toBeDefined(); + expect(typeof body.refreshToken).toBe("string"); + expect(body.user).toEqual({ + 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 () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "invalid-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); + }); + + it("returns 401 for expired refresh token", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "expired-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); + }); + + it("returns 401 when user not found", async () => { + 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", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "valid-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("USER_NOT_FOUND"); + }); + + it("returns 422 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"); + }); + + it("returns 422 for empty refresh token", async () => { + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); +}); diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts new file mode 100644 index 0000000..25c959b --- /dev/null +++ b/src/server/routes/auth.ts @@ -0,0 +1,199 @@ +import { createHash, randomBytes } from "node:crypto"; +import * as argon2 from "argon2"; +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { Errors } from "../middleware/index.js"; +import { + type RefreshTokenRepository, + refreshTokenRepository, + type UserRepository, + userRepository, +} from "../repositories/index.js"; +import { + createUserSchema, + loginSchema, + refreshTokenSchema, +} from "../schemas/index.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); +} +const ACCESS_TOKEN_EXPIRES_IN = 60 * 15; // 15 minutes +const REFRESH_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 7; // 7 days + +function generateRefreshToken(): string { + return randomBytes(32).toString("hex"); +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export interface AuthDependencies { + userRepo: UserRepository; + refreshTokenRepo: RefreshTokenRepository; +} + +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); + + 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 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", + ); + } + + // 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, + }, + }); + }); + + return auth; +} + +// Default auth router with real repositories for production use +export const auth = createAuthRouter({ + userRepo: userRepository, + refreshTokenRepo: refreshTokenRepository, +}); diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts new file mode 100644 index 0000000..0b89782 --- /dev/null +++ b/src/server/routes/index.ts @@ -0,0 +1 @@ +export { auth } from "./auth.js"; |
