File
Blob: src/worker/routes/auth.ts
| 1 | import { Hono } from "hono"; |
| 2 | import { getCookie } from "hono/cookie"; |
| 3 | import { eq } from "drizzle-orm"; |
| 4 | import { jwtVerify } from "jose"; |
| 5 | |
| 6 | import type { AppContext } from "@/worker/app-context"; |
| 7 | import { users } from "@/worker/db/d1/schema"; |
| 8 | import { requireAuth } from "@/worker/middleware/auth"; |
| 9 | import { rateLimit } from "@/worker/middleware/rate-limit"; |
| 10 | import { createAccessToken, clearRefreshCookie, toUserResponse, getJwtSecret, REFRESH_COOKIE } from "@/worker/lib/auth"; |
| 11 | import { parseBody } from "@/worker/lib/validate"; |
| 12 | import { createLogger } from "@/worker/lib/logger"; |
| 13 | import { JWT_ALGORITHM } from "@/worker/lib/constants"; |
| 14 | import { UpdateProfileRequest } from "@/shared/types"; |
| 15 | |
| 16 | const auth = new Hono<AppContext>(); |
| 17 | const log = createLogger("auth"); |
| 18 | |
| 19 | // POST /auth/refresh |
| 20 | auth.post("/auth/refresh", rateLimit("RL_AUTH"), async (c) => { |
| 21 | const refreshToken = getCookie(c, REFRESH_COOKIE); |
| 22 | log.debug("refresh_attempt"); |
| 23 | |
| 24 | if (!refreshToken) { |
| 25 | log.info("refresh_failed", { reason: "no_token" }); |
| 26 | return c.json({ error: "unauthorized", message: "No refresh token" }, 401); |
| 27 | } |
| 28 | |
| 29 | try { |
| 30 | const { payload } = await jwtVerify(refreshToken, getJwtSecret(c.env), { |
| 31 | algorithms: [JWT_ALGORITHM], |
| 32 | }); |
| 33 | |
| 34 | if (!payload.sub || payload.type !== "refresh") { |
| 35 | log.info("refresh_failed", { reason: "invalid_token" }); |
| 36 | return c.json({ error: "unauthorized", message: "Invalid refresh token" }, 401); |
| 37 | } |
| 38 | |
| 39 | const db = c.get("db"); |
| 40 | const user = await db.select().from(users).where(eq(users.id, payload.sub)).get(); |
| 41 | |
| 42 | if (!user) { |
| 43 | log.info("refresh_failed", { reason: "user_not_found" }); |
| 44 | clearRefreshCookie(c); |
| 45 | return c.json({ error: "unauthorized", message: "User not found" }, 401); |
| 46 | } |
| 47 | const accessToken = await createAccessToken(user.id, c.env); |
| 48 | log.info("refresh_success", { userId: user.id }); |
| 49 | |
| 50 | return c.json({ user: toUserResponse(user), accessToken }); |
| 51 | } catch { |
| 52 | log.info("refresh_failed", { reason: "expired_or_invalid" }); |
| 53 | clearRefreshCookie(c); |
| 54 | return c.json({ error: "unauthorized", message: "Invalid or expired refresh token" }, 401); |
| 55 | } |
| 56 | }); |
| 57 | |
| 58 | // POST /auth/logout |
| 59 | auth.post("/auth/logout", rateLimit("RL_API"), (c) => { |
| 60 | log.debug("logout"); |
| 61 | clearRefreshCookie(c); |
| 62 | return c.json({ ok: true }); |
| 63 | }); |
| 64 | |
| 65 | // GET /auth/me |
| 66 | auth.get("/auth/me", requireAuth, rateLimit("RL_API"), async (c) => { |
| 67 | const user = c.get("user")!; |
| 68 | return c.json({ user: toUserResponse(user) }); |
| 69 | }); |
| 70 | |
| 71 | // PATCH /auth/me - Update profile |
| 72 | auth.patch("/auth/me", requireAuth, rateLimit("RL_API"), async (c) => { |
| 73 | const user = c.get("user")!; |
| 74 | const db = c.get("db"); |
| 75 | |
| 76 | const data = await parseBody(c, UpdateProfileRequest); |
| 77 | if (data instanceof Response) return data; |
| 78 | |
| 79 | const updates: Record<string, string | null> = { updated_at: new Date().toISOString() }; |
| 80 | if (data.name !== undefined) updates.name = data.name; |
| 81 | if (data.avatar_url !== undefined) updates.avatar_url = data.avatar_url; |
| 82 | |
| 83 | await db.update(users).set(updates).where(eq(users.id, user.id)); |
| 84 | const updated = await db.select().from(users).where(eq(users.id, user.id)).get(); |
| 85 | |
| 86 | return c.json({ user: toUserResponse(updated!) }); |
| 87 | }); |
| 88 | |
| 89 | export { auth }; |