aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--pkgs/server/src/repositories/index.ts3
-rw-r--r--pkgs/server/src/repositories/refresh-token.ts35
-rw-r--r--pkgs/server/src/repositories/types.ts46
-rw-r--r--pkgs/server/src/repositories/user.ts55
-rw-r--r--pkgs/server/src/routes/auth.test.ts293
-rw-r--r--pkgs/server/src/routes/auth.ts351
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,
+});