/** * Feishu OAuth + session routes for org admin web login. */ import { randomBytes } from "node:crypto"; import type { PrismaClient } from "@prisma/client"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { buildAuthorizeUrl, DEFAULT_OAUTH_SCOPE, exchangeCodeForUser, type FeishuOAuthConfig, } from "../auth/feishuOAuth.js"; import { HttpError, requireSession, type GuardDeps, } from "../auth/guards.js"; import { OAUTH_STATE_COOKIE_NAME, SESSION_COOKIE_NAME, signOAuthState, signSession, verifyOAuthState, } from "../auth/session.js"; import { handleRouteError } from "../errors.js"; export interface AuthRouteConfig { readonly prisma: PrismaClient; readonly sessionSecret: string; readonly publicBaseUrl: string; readonly feishuAppId: string; readonly feishuAppSecret: string; readonly oauthScope?: string; readonly cookieSecure: boolean; readonly fetchImpl?: typeof fetch; } export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise { const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`; const oauthConfig: FeishuOAuthConfig = { appId: config.feishuAppId, appSecret: config.feishuAppSecret, redirectUri, scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE, ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), }; app.get("/auth/feishu", async (request, reply) => { try { const returnTo = sanitizeReturnTo( typeof request.query === "object" && request.query !== null && "returnTo" in request.query ? String((request.query as { returnTo?: string }).returnTo ?? "") : "", ); const nonce = randomBytes(16).toString("hex"); const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret); // state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit. reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, { path: "/", httpOnly: true, sameSite: "lax", secure: config.cookieSecure, maxAge: 600, }); const url = buildAuthorizeUrl(oauthConfig, stateToken); return reply.redirect(url); } catch (err) { return handleRouteError(reply, err); } }); app.get("/auth/feishu/callback", async (request, reply) => { try { const query = request.query as { code?: string; state?: string; error?: string; }; if (typeof query.error === "string" && query.error !== "") { return reply.redirect(`/admin/login?error=${encodeURIComponent(query.error)}`); } const code = query.code; const state = query.state; if (typeof code !== "string" || code === "" || typeof state !== "string" || state === "") { throw new HttpError(400, "bad_request", "missing OAuth code or state"); } const statePayload = verifyOAuthState(state, config.sessionSecret); if (statePayload === null) { throw new HttpError(400, "bad_request", "invalid or expired OAuth state"); } const cookieNonce = request.cookies[OAUTH_STATE_COOKIE_NAME]; if (cookieNonce === undefined || cookieNonce !== statePayload.nonce) { throw new HttpError(400, "bad_request", "OAuth state mismatch"); } reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" }); const feishuUser = await exchangeCodeForUser(oauthConfig, code); const user = await config.prisma.user.upsert({ where: { feishuOpenId: feishuUser.openId }, create: { feishuOpenId: feishuUser.openId, displayName: feishuUser.displayName, avatarUrl: feishuUser.avatarUrl, }, update: { displayName: feishuUser.displayName, avatarUrl: feishuUser.avatarUrl, }, }); setSessionCookie(reply, config, { userId: user.id, feishuOpenId: user.feishuOpenId, }); const destination = await resolvePostLoginRedirect( config.prisma, user.id, statePayload.returnTo, ); return reply.redirect(destination); } catch (err) { request.log.error({ err }, "feishu oauth callback failed"); return reply.redirect(`/admin/login?error=${encodeURIComponent("oauth_failed")}`); } }); app.post("/auth/logout", async (_request, reply) => { reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); return reply.status(204).send(); }); app.get("/api/me", async (request, reply) => { try { const auth = await requireSession(request, reply, guardDeps); if (auth === null) return; const memberships = await config.prisma.organizationMembership.findMany({ where: { userId: auth.user.id, revokedAt: null }, select: { role: true, organization: { select: { id: true, slug: true, name: true, status: true }, }, }, orderBy: { createdAt: "asc" }, }); return { user: { id: auth.user.id, feishuOpenId: auth.user.feishuOpenId, displayName: auth.user.displayName, avatarUrl: auth.user.avatarUrl, }, organizations: memberships.map((m) => ({ id: m.organization.id, slug: m.organization.slug, name: m.organization.name, status: m.organization.status, role: m.role, })), }; } catch (err) { return handleRouteError(reply, err); } }); // Minimal login page until admin SPA lands (PR7). app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => { const q = request.query as { error?: string; returnTo?: string }; const returnTo = sanitizeReturnTo(q.returnTo ?? ""); const loginHref = returnTo === "/admin" ? "/auth/feishu" : `/auth/feishu?returnTo=${encodeURIComponent(returnTo)}`; const errorHtml = typeof q.error === "string" && q.error !== "" ? `

Login failed: ${escapeHtml(q.error)}

` : ""; const html = ` CPH Org Admin — Login

Org Admin

Sign in with Feishu to manage your organization.

${errorHtml} Login with Feishu
`; return reply.type("text/html").send(html); }); } export function setSessionCookie( reply: FastifyReply, config: Pick, identity: { readonly userId: string; readonly feishuOpenId: string }, ): void { const token = signSession(identity, config.sessionSecret); reply.setCookie(SESSION_COOKIE_NAME, token, { path: "/", httpOnly: true, sameSite: "lax", secure: config.cookieSecure, maxAge: 7 * 24 * 60 * 60, }); } /** Exported for tests that need a pre-authenticated cookie value. */ export function mintSessionToken( identity: { readonly userId: string; readonly feishuOpenId: string }, sessionSecret: string, ): string { return signSession(identity, sessionSecret); } export function sessionCookieHeader(token: string): string { return `${SESSION_COOKIE_NAME}=${token}`; } async function resolvePostLoginRedirect( prisma: PrismaClient, userId: string, returnTo: string, ): Promise { if (returnTo !== "/admin" && returnTo.startsWith("/admin")) { return returnTo; } const membership = await prisma.organizationMembership.findFirst({ where: { userId, revokedAt: null, role: { in: ["OWNER", "ADMIN"] }, organization: { status: "ACTIVE" }, }, select: { organization: { select: { slug: true } } }, orderBy: { createdAt: "asc" }, }); if (membership !== null) { return `/admin/org/${membership.organization.slug}`; } // Member-only or no org: still land on a shell page (SPA will explain). const any = await prisma.organizationMembership.findFirst({ where: { userId, revokedAt: null, organization: { status: "ACTIVE" } }, select: { organization: { select: { slug: true } } }, orderBy: { createdAt: "asc" }, }); if (any !== null) { return `/admin/org/${any.organization.slug}`; } return "/admin/login?error=no_organization"; } /** * Only allow relative same-origin paths under /admin to avoid open redirects. */ export function sanitizeReturnTo(raw: string): string { if (raw === "" || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("://")) { return "/admin"; } if (!raw.startsWith("/admin")) { return "/admin"; } return raw; } function trimTrailingSlash(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); }