diff options
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 34 | ||||
| -rw-r--r-- | src/client/api/client.test.ts | 267 | ||||
| -rw-r--r-- | src/client/api/client.ts | 165 | ||||
| -rw-r--r-- | src/client/api/index.ts | 15 | ||||
| -rw-r--r-- | src/client/api/types.ts | 24 | ||||
| -rw-r--r-- | src/server/index.ts | 20 | ||||
| -rw-r--r-- | src/server/routes/auth.test.ts | 36 | ||||
| -rw-r--r-- | src/server/routes/auth.ts | 274 |
10 files changed, 659 insertions, 180 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index fde652d..720639e 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -48,7 +48,7 @@ Smaller features first to enable early MVP validation. ### Frontend Foundation - [x] Initialize React + Vite - [x] Setup routing -- [ ] API client (fetch wrapper with auth headers) +- [x] API client (fetch wrapper with auth headers) - [ ] Auth store (token management) ### Auth Pages diff --git a/package.json b/package.json index 0ee0d70..3936102 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "type": "module", "dependencies": { "@hono/node-server": "^1.19.6", + "@hono/zod-validator": "^0.7.5", "argon2": "^0.44.0", "drizzle-orm": "^0.45.0", "hono": "^4.10.7", @@ -39,6 +40,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@hono/cli": "^0.1.3", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/node": "^24.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b5619e..a58f707 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hono/node-server': specifier: ^1.19.6 version: 1.19.6(hono@4.10.7) + '@hono/zod-validator': + specifier: ^0.7.5 + version: 0.7.5(hono@4.10.7)(zod@4.1.13) argon2: specifier: ^0.44.0 version: 0.44.0 @@ -39,6 +42,9 @@ importers: '@biomejs/biome': specifier: ^2.3.8 version: 2.3.8 + '@hono/cli': + specifier: ^0.1.3 + version: 0.1.3 '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -564,12 +570,22 @@ packages: cpu: [x64] os: [win32] + '@hono/cli@0.1.3': + resolution: {integrity: sha512-jqIoJyCXKxCR6kd2Grxg9hRczju39r3xVu5dK4FG5wVxe257Bh09Qhw8pasY12VE/l46gL0sQG/XPgy+rn9yPA==} + hasBin: true + '@hono/node-server@1.19.6': resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 + '@hono/zod-validator@0.7.5': + resolution: {integrity: sha512-n4l4hutkfYU07PzRUHBOVzUEn38VSfrh+UVE5d0w4lyfWDOEhzxIupqo5iakRiJL44c3vTuFJBvcmUl8b9agIA==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.25.0 || ^4.0.0 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -843,6 +859,10 @@ packages: resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} engines: {node: '>=18'} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1830,10 +1850,22 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@hono/cli@0.1.3': + dependencies: + '@hono/node-server': 1.19.6(hono@4.10.7) + commander: 14.0.2 + esbuild: 0.25.12 + hono: 4.10.7 + '@hono/node-server@1.19.6(hono@4.10.7)': dependencies: hono: 4.10.7 + '@hono/zod-validator@0.7.5(hono@4.10.7)(zod@4.1.13)': + dependencies: + hono: 4.10.7 + zod: 4.1.13 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2086,6 +2118,8 @@ snapshots: chai@6.2.1: {} + commander@14.0.2: {} + convert-source-map@2.0.0: {} cross-env@10.1.0: diff --git a/src/client/api/client.test.ts b/src/client/api/client.test.ts new file mode 100644 index 0000000..3cfe190 --- /dev/null +++ b/src/client/api/client.test.ts @@ -0,0 +1,267 @@ +/** + * @vitest-environment jsdom + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import { + ApiClient, + ApiClientError, + localStorageTokenStorage, + type TokenStorage, +} from "./client"; + +function createMockTokenStorage(): TokenStorage & { + getTokens: Mock; + setTokens: Mock; + clearTokens: Mock; +} { + return { + getTokens: vi.fn(), + setTokens: vi.fn(), + clearTokens: vi.fn(), + }; +} + +function mockFetch(responses: Array<{ status: number; body?: unknown }>) { + let callIndex = 0; + return vi.fn(async () => { + const response = responses[callIndex++]; + if (!response) { + throw new Error("Unexpected fetch call"); + } + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + json: async () => response.body, + }; + }) as Mock; +} + +describe("ApiClient", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("register", () => { + it("sends registration request without auth header", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ + tokenStorage: mockStorage, + baseUrl: "http://localhost:3000", + }); + + const responseBody = { user: { id: "123", username: "testuser" } }; + global.fetch = mockFetch([{ status: 201, body: responseBody }]); + + const result = await client.register("testuser", "password123"); + + expect(result).toEqual(responseBody); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:3000/api/auth/register", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + username: "testuser", + password: "password123", + }), + }), + ); + + const call = (global.fetch as Mock).mock.calls[0] as [ + string, + RequestInit, + ]; + const headers = call[1].headers as Record<string, string>; + expect(headers.Authorization).toBeUndefined(); + }); + + it("throws ApiClientError on registration failure", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + global.fetch = mockFetch([ + { + status: 409, + body: { error: "Username already exists", code: "USERNAME_EXISTS" }, + }, + ]); + + try { + await client.register("testuser", "password"); + expect.fail("Expected ApiClientError to be thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiClientError); + const error = e as ApiClientError; + expect(error.message).toBe("Username already exists"); + expect(error.status).toBe(409); + expect(error.code).toBe("USERNAME_EXISTS"); + } + }); + }); + + describe("login", () => { + it("sends login request and stores tokens", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + const responseBody = { + accessToken: "access-token", + refreshToken: "refresh-token", + user: { id: "123", username: "testuser" }, + }; + global.fetch = mockFetch([{ status: 200, body: responseBody }]); + + const result = await client.login("testuser", "password123"); + + expect(result).toEqual(responseBody); + expect(mockStorage.setTokens).toHaveBeenCalledWith({ + accessToken: "access-token", + refreshToken: "refresh-token", + }); + }); + + it("throws ApiClientError on invalid credentials", async () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + global.fetch = mockFetch([ + { + status: 401, + body: { + error: "Invalid username or password", + code: "INVALID_CREDENTIALS", + }, + }, + ]); + + try { + await client.login("testuser", "wrongpassword"); + expect.fail("Expected ApiClientError to be thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiClientError); + const error = e as ApiClientError; + expect(error.message).toBe("Invalid username or password"); + expect(error.status).toBe(401); + expect(error.code).toBe("INVALID_CREDENTIALS"); + } + }); + }); + + describe("logout", () => { + it("clears tokens from storage", () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + client.logout(); + + expect(mockStorage.clearTokens).toHaveBeenCalled(); + }); + }); + + describe("isAuthenticated", () => { + it("returns true when tokens exist", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue({ + accessToken: "token", + refreshToken: "refresh", + }); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.isAuthenticated()).toBe(true); + }); + + it("returns false when no tokens", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue(null); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.isAuthenticated()).toBe(false); + }); + }); + + describe("getAuthHeader", () => { + it("returns auth header when tokens exist", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue({ + accessToken: "my-access-token", + refreshToken: "my-refresh-token", + }); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.getAuthHeader()).toEqual({ + Authorization: "Bearer my-access-token", + }); + }); + + it("returns undefined when no tokens", () => { + const mockStorage = createMockTokenStorage(); + mockStorage.getTokens.mockReturnValue(null); + const client = new ApiClient({ tokenStorage: mockStorage }); + + expect(client.getAuthHeader()).toBeUndefined(); + }); + }); + + describe("rpc client", () => { + it("exposes typed RPC client", () => { + const mockStorage = createMockTokenStorage(); + const client = new ApiClient({ tokenStorage: mockStorage }); + + // RPC client should have auth routes + expect(client.rpc.api.auth.login).toBeDefined(); + expect(client.rpc.api.auth.register).toBeDefined(); + expect(client.rpc.api.auth.refresh).toBeDefined(); + }); + }); +}); + +describe("localStorageTokenStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("stores and retrieves tokens", () => { + const tokens = { accessToken: "access", refreshToken: "refresh" }; + localStorageTokenStorage.setTokens(tokens); + + const retrieved = localStorageTokenStorage.getTokens(); + expect(retrieved).toEqual(tokens); + }); + + it("returns null when no tokens stored", () => { + expect(localStorageTokenStorage.getTokens()).toBeNull(); + }); + + it("clears tokens", () => { + localStorageTokenStorage.setTokens({ + accessToken: "a", + refreshToken: "r", + }); + localStorageTokenStorage.clearTokens(); + + expect(localStorageTokenStorage.getTokens()).toBeNull(); + }); + + it("returns null on invalid JSON", () => { + localStorage.setItem("kioku_tokens", "not-valid-json"); + expect(localStorageTokenStorage.getTokens()).toBeNull(); + }); +}); diff --git a/src/client/api/client.ts b/src/client/api/client.ts new file mode 100644 index 0000000..f9b8a61 --- /dev/null +++ b/src/client/api/client.ts @@ -0,0 +1,165 @@ +import { hc } from "hono/client"; +import type { AppType } from "../../server/index.js"; +import type { ApiError, AuthResponse, Tokens } from "./types"; + +export class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } +} + +export interface TokenStorage { + getTokens(): Tokens | null; + setTokens(tokens: Tokens): void; + clearTokens(): void; +} + +const TOKEN_STORAGE_KEY = "kioku_tokens"; + +export const localStorageTokenStorage: TokenStorage = { + getTokens(): Tokens | null { + const stored = localStorage.getItem(TOKEN_STORAGE_KEY); + if (!stored) return null; + try { + return JSON.parse(stored) as Tokens; + } catch { + return null; + } + }, + setTokens(tokens: Tokens): void { + localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(tokens)); + }, + clearTokens(): void { + localStorage.removeItem(TOKEN_STORAGE_KEY); + }, +}; + +export interface ApiClientOptions { + baseUrl?: string; + tokenStorage?: TokenStorage; +} + +// RPC client type - use this for type-safe API calls +export type Client = ReturnType<typeof hc<AppType>>; + +export function createClient(baseUrl: string): Client { + return hc<AppType>(baseUrl); +} + +export class ApiClient { + private tokenStorage: TokenStorage; + private refreshPromise: Promise<boolean> | null = null; + public readonly rpc: Client; + + constructor(options: ApiClientOptions = {}) { + const baseUrl = options.baseUrl ?? window.location.origin; + this.tokenStorage = options.tokenStorage ?? localStorageTokenStorage; + this.rpc = createClient(baseUrl); + } + + private async handleResponse<T>(response: Response): Promise<T> { + if (!response.ok) { + const errorBody = (await response.json().catch(() => ({}))) as ApiError; + throw new ApiClientError( + errorBody.error || `Request failed with status ${response.status}`, + response.status, + errorBody.code, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise<T>; + } + + private async refreshToken(): Promise<boolean> { + if (this.refreshPromise) { + return this.refreshPromise; + } + + this.refreshPromise = this.doRefreshToken(); + + try { + return await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + private async doRefreshToken(): Promise<boolean> { + const tokens = this.tokenStorage.getTokens(); + if (!tokens?.refreshToken) { + return false; + } + + try { + const res = await this.rpc.api.auth.refresh.$post({ + json: { refreshToken: tokens.refreshToken }, + }); + + if (!res.ok) { + return false; + } + + const data = await res.json(); + this.tokenStorage.setTokens({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + }); + return true; + } catch { + return false; + } + } + + async register(username: string, password: string) { + const res = await this.rpc.api.auth.register.$post({ + json: { username, password }, + }); + return this.handleResponse<{ user: { id: string; username: string } }>(res); + } + + async login(username: string, password: string): Promise<AuthResponse> { + const res = await this.rpc.api.auth.login.$post({ + json: { username, password }, + }); + + const data = await this.handleResponse<AuthResponse>(res); + + this.tokenStorage.setTokens({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + }); + + return data; + } + + logout(): void { + this.tokenStorage.clearTokens(); + } + + isAuthenticated(): boolean { + return this.tokenStorage.getTokens() !== null; + } + + getTokens(): Tokens | null { + return this.tokenStorage.getTokens(); + } + + getAuthHeader(): { Authorization: string } | undefined { + const tokens = this.tokenStorage.getTokens(); + if (tokens?.accessToken) { + return { Authorization: `Bearer ${tokens.accessToken}` }; + } + return undefined; + } +} + +export const apiClient = new ApiClient(); diff --git a/src/client/api/index.ts b/src/client/api/index.ts new file mode 100644 index 0000000..2d95c14 --- /dev/null +++ b/src/client/api/index.ts @@ -0,0 +1,15 @@ +export { + ApiClient, + ApiClientError, + type ApiClientOptions, + apiClient, + localStorageTokenStorage, + type TokenStorage, +} from "./client"; +export type { + ApiError, + AuthResponse, + RegisterResponse, + Tokens, + User, +} from "./types"; diff --git a/src/client/api/types.ts b/src/client/api/types.ts new file mode 100644 index 0000000..1ba3624 --- /dev/null +++ b/src/client/api/types.ts @@ -0,0 +1,24 @@ +export interface User { + id: string; + username: string; +} + +export interface AuthResponse { + accessToken: string; + refreshToken: string; + user: User; +} + +export interface RegisterResponse { + user: User; +} + +export interface ApiError { + error: string; + code?: string; +} + +export interface Tokens { + accessToken: string; + refreshToken: string; +} diff --git a/src/server/index.ts b/src/server/index.ts index 01a489f..d157f74 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -9,15 +9,17 @@ const app = new Hono(); app.use("*", logger()); app.onError(errorHandler); -app.get("/", (c) => { - return c.json({ message: "Kioku API" }); -}); - -app.get("/api/health", (c) => { - return c.json({ status: "ok" }); -}); - -app.route("/api/auth", auth); +// Chain routes for RPC type inference +const routes = app + .get("/", (c) => { + return c.json({ message: "Kioku API" }, 200); + }) + .get("/api/health", (c) => { + return c.json({ status: "ok" }, 200); + }) + .route("/api/auth", auth); + +export type AppType = typeof routes; const port = Number(process.env.PORT) || 3000; console.log(`Server is running on port ${port}`); diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts index 95fd6e9..3ba504e 100644 --- a/src/server/routes/auth.test.ts +++ b/src/server/routes/auth.test.ts @@ -106,7 +106,7 @@ describe("POST /register", () => { }); }); - it("returns 422 for invalid username", async () => { + it("returns 400 for invalid username", async () => { const res = await app.request("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -116,12 +116,10 @@ describe("POST /register", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as RegisterResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); - it("returns 422 for password too short", async () => { + it("returns 400 for password too short", async () => { const res = await app.request("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -131,9 +129,7 @@ describe("POST /register", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as RegisterResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); it("returns 409 for existing username", async () => { @@ -244,7 +240,7 @@ describe("POST /login", () => { expect(body.error?.code).toBe("INVALID_CREDENTIALS"); }); - it("returns 422 for missing username", async () => { + it("returns 400 for missing username", async () => { const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -254,12 +250,10 @@ describe("POST /login", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); - it("returns 422 for missing password", async () => { + it("returns 400 for missing password", async () => { const res = await app.request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -269,9 +263,7 @@ describe("POST /login", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); }); @@ -400,19 +392,17 @@ describe("POST /refresh", () => { expect(body.error?.code).toBe("USER_NOT_FOUND"); }); - it("returns 422 for missing refresh token", async () => { + it("returns 400 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"); + expect(res.status).toBe(400); }); - it("returns 422 for empty refresh token", async () => { + it("returns 400 for empty refresh token", async () => { const res = await app.request("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -421,8 +411,6 @@ describe("POST /refresh", () => { }), }); - expect(res.status).toBe(422); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); + expect(res.status).toBe(400); }); }); diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index f0c0428..144bbae 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; +import { zValidator } from "@hono/zod-validator"; import * as argon2 from "argon2"; import { Hono } from "hono"; import { sign } from "hono/jwt"; @@ -40,159 +41,140 @@ export interface AuthDependencies { 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); + return new Hono() + .post("/register", zValidator("json", createUserSchema), async (c) => { + const { username, password } = c.req.valid("json"); + + // 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); + }) + .post("/login", zValidator("json", loginSchema), async (c) => { + const { username, password } = c.req.valid("json"); + + // 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, + }, + getJwtSecret(), + ); - if (!user) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", + // 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, + }, + }, + 200, ); - } - - // Verify password - const isPasswordValid = await argon2.verify(user.passwordHash, password); - if (!isPasswordValid) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", + }) + .post("/refresh", zValidator("json", refreshTokenSchema), async (c) => { + const { refreshToken } = c.req.valid("json"); + 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, + }, + getJwtSecret(), ); - } - - // 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, - }, - getJwtSecret(), - ); - - // 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", + // 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, + }, + }, + 200, ); - } - - // 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, - }, - getJwtSecret(), - ); - - // 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 |
