import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { getCookie, setCookie } from "hono/cookie"; import { db } from "./db"; import { users } from "./db/schema"; import { feishuClient } from "./feishu"; import { logger } from "./lib/logger"; const auth = new Hono(); const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "") .split(",") .map((email) => email.trim()) .filter(Boolean); // Get the login URL for frontend to redirect auth.get("/login-url", (c) => { const appId = process.env.FEISHU_APP_ID; const redirectUri = encodeURIComponent( process.env.REDIRECT_URI || "http://localhost:5173/auth/callback", ); const state = crypto.randomUUID(); // Store state in cookie for CSRF protection setCookie(c, "oauth_state", state, { httpOnly: true, secure: process.env.NODE_ENV === "production", maxAge: 600, // 10 minutes sameSite: "Lax", }); const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`; return c.json({ loginUrl }); }); // Handle OAuth callback auth.get("/callback", async (c) => { const code = c.req.query("code"); const state = c.req.query("state"); const storedState = getCookie(c, "oauth_state"); // Verify state for CSRF protection if (!state || state !== storedState) { return c.json({ error: "Invalid state parameter" }, 400); } if (!code) { return c.json({ error: "No code provided" }, 400); } try { // Exchange code for user access token and user info const userData = await feishuClient.getUserAccessToken(code); if (!userData) { logger.error("[Auth] Failed to get user data from code"); return c.json({ error: "Failed to get user info from Feishu" }, 500); } // Check if user exists, otherwise create let user = await db.query.users.findFirst({ where: eq(users.feishuUserId, userData.open_id), }); const isAdmin = ADMIN_EMAILS.includes(userData.email || ""); if (!user) { // Create new user const result = await db .insert(users) .values({ name: userData.name, feishuUserId: userData.open_id, email: userData.email || null, isAdmin, }) .returning(); user = result[0]; } else { // Update user info (don't overwrite admin/trusted status from feishu logic unless it's a new admin) const result = await db .update(users) .set({ name: userData.name, email: userData.email || user.email, isAdmin: user.isAdmin || isAdmin, // Keep admin if already admin or in ADMIN_EMAILS }) .where(eq(users.id, user.id)) .returning(); user = result[0]; } // Set session cookie setCookie( c, "session", JSON.stringify({ id: user.id, name: user.name, email: user.email, isAdmin: user.isAdmin, isTrusted: user.isTrusted, personalToken: user.personalToken, }), { httpOnly: true, secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 24 * 7, // 7 days sameSite: "Lax", }, ); return c.json({ success: true, user: { id: user.id, name: user.name, email: user.email, isAdmin: user.isAdmin, isTrusted: user.isTrusted, }, }); } catch (error) { logger.error({ err: error }, "OAuth callback error"); return c.json({ error: "Authentication failed" }, 500); } }); // Get current user from session auth.get("/me", (c) => { const sessionCookie = getCookie(c, "session"); if (!sessionCookie) { return c.json({ error: "Not authenticated" }, 401); } try { const session = sessionCookie ? JSON.parse(sessionCookie) : null; if (!session) { return c.json({ error: "Not authenticated" }, 401); } // Normalize user object to ensure id is present (handle legacy session with userId) const user = { ...session, id: session.id || session.userId, }; return c.json({ user }); } catch (error) { logger.error({ err: error }, "[Auth] Failed to parse session cookie"); return c.json({ error: "Invalid session" }, 401); } }); // Logout auth.post("/logout", (c) => { setCookie(c, "session", "", { maxAge: 0, }); return c.json({ success: true }); }); export default auth;