/** * 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 { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js"; import { upsertScopedFeishuIdentityInTransaction } from "../../feishu/identityNamespace.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import { buildAuthorizeUrl, DEFAULT_OAUTH_SCOPE, exchangeCodeForUser, FeishuOAuthError, 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, type SessionIdentity, } 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 secretEnvelope: LocalSecretEnvelope; readonly oauthScope?: string; readonly cookieSecure: boolean; readonly fetchImpl?: typeof fetch; readonly allowLegacyFeishuOAuth?: boolean; } 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 legacyOauthConfig: FeishuOAuthConfig = { appId: config.feishuAppId, appSecret: config.feishuAppSecret, redirectUri, scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE, ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), }; if (config.allowLegacyFeishuOAuth === true) { 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); setOAuthNonceCookie(reply, nonce, config.cookieSecure); const url = buildAuthorizeUrl(legacyOauthConfig, stateToken); return reply.redirect(url); } catch (err) { return handleRouteError(reply, err); } }); } app.get("/auth/feishu/:orgSlug", async (request, reply) => { try { const { orgSlug } = request.params as { orgSlug: string }; const organization = await config.prisma.organization.findUnique({ where: { slug: orgSlug }, select: { id: true, status: true }, }); if (organization === null) throw new HttpError(404, "org_not_found", `organization not found: ${orgSlug}`); if (organization.status !== "ACTIVE") { throw new HttpError(403, "org_not_active", `organization is ${organization.status}`); } const credential = await resolveActiveFeishuApplication( config.prisma, config.secretEnvelope, { organizationId: organization.id }, ); 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, organizationId: organization.id, connectionId: credential.connectionId, }, config.sessionSecret); setOAuthNonceCookie(reply, nonce, config.cookieSecure); return reply.redirect(buildAuthorizeUrl({ appId: credential.appId, appSecret: credential.appSecret, redirectUri, scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE, ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), }, stateToken)); } 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: "/" }); let oauthConfig = legacyOauthConfig; if (statePayload.connectionId === undefined && config.allowLegacyFeishuOAuth !== true) { throw new HttpError(400, "bad_request", "unscoped Feishu OAuth is disabled"); } if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) { const credential = await resolveActiveFeishuApplication( config.prisma, config.secretEnvelope, { connectionId: statePayload.connectionId }, ); if (credential.organizationId !== statePayload.organizationId) { throw new HttpError(400, "bad_request", "OAuth state Organization scope mismatch"); } oauthConfig = { appId: credential.appId, appSecret: credential.appSecret, redirectUri, scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE, ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), }; } const feishuUser = await exchangeCodeForUser(oauthConfig, code); let userId: string; if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) { const connectionId = statePayload.connectionId; const organizationId = statePayload.organizationId; const identity = await config.prisma.$transaction(async (tx) => { const resolved = await upsertScopedFeishuIdentityInTransaction(tx, { connectionId, expectedOrganizationId: organizationId, openId: feishuUser.openId, ...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}), displayName: feishuUser.displayName, ...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}), }); const activeMembership = await tx.organizationMembership.findFirst({ where: { organizationId, userId: resolved.userId, revokedAt: null, }, select: { id: true }, }); if (activeMembership === null) { const revokedMembership = await tx.organizationMembership.findFirst({ where: { organizationId, userId: resolved.userId, revokedAt: { not: null }, }, select: { id: true }, }); if (revokedMembership === null) { await tx.organizationMembership.create({ data: { organizationId, userId: resolved.userId, role: "MEMBER", }, }); await tx.auditEntry.create({ data: { organizationId, actorUserId: resolved.userId, action: "organization_member.oauth_auto_joined", metadata: { connectionId: resolved.connectionId, role: "MEMBER" }, }, }); } } return resolved; }); userId = identity.userId; setSessionCookie(reply, config, { userId, feishuIdentityId: identity.identityId, feishuConnectionId: identity.connectionId, feishuOrganizationId: identity.organizationId, }); } else { 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, }, }); userId = user.id; setSessionCookie(reply, config, { userId, feishuOpenId: user.feishuOpenId, }); } const destination = await resolvePostLoginRedirect( config.prisma, userId, statePayload.returnTo, statePayload.organizationId !== undefined ? { intendedOrganizationId: statePayload.organizationId } : {}, ); return reply.redirect(destination); } catch (err) { request.log.error({ requestId: request.id, operation: "feishu_oauth.callback", ...(err instanceof FeishuOAuthError ? { errorCode: err.code, failureCategory: err.category, ...(err.upstreamStatus !== undefined ? { upstreamStatus: err.upstreamStatus } : {}), } : { errorCode: "feishu_oauth_callback_failed", errorType: err instanceof Error ? err.name : typeof err, 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("/auth/feishu/complete", async (request, reply) => { const query = request.query as { org?: string }; const organizationName = typeof query.org === "string" && query.org.trim() !== "" ? query.org.trim() : "当前组织"; return reply.type("text/html").send(` Educraft 登录成功

登录并加入组织成功

你已加入 ${escapeHtml(organizationName)}。

现在可以关闭本页面,返回飞书群再次 @机器人继续使用。

`); }); 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, ...(auth.authenticationOrganizationId !== undefined ? { organizationId: auth.authenticationOrganizationId } : {}), }, select: { role: true, organization: { select: { id: true, slug: true, name: true, status: true }, }, }, orderBy: { createdAt: "asc" }, }); return { user: { id: auth.user.id, feishuOpenId: auth.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; orgSlug?: string }; const returnTo = sanitizeReturnTo(q.returnTo ?? ""); const orgSlug = typeof q.orgSlug === "string" && /^[a-z0-9][a-z0-9-]*$/.test(q.orgSlug) ? q.orgSlug : null; const loginHref = orgSlug === null ? null : returnTo === "/admin" ? `/auth/feishu/${encodeURIComponent(orgSlug)}` : `/auth/feishu/${encodeURIComponent(orgSlug)}?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} ${loginHref === null ? "

请从组织专属登录链接进入。

" : `Login with Feishu`}
`; return reply.type("text/html").send(html); }); } export function setSessionCookie( reply: FastifyReply, config: Pick, identity: SessionIdentity, ): 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: SessionIdentity, 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, options: { readonly intendedOrganizationId?: string | undefined } = {}, ): Promise { if (options.intendedOrganizationId !== undefined) { const intended = await prisma.organizationMembership.findFirst({ where: { userId, organizationId: options.intendedOrganizationId, revokedAt: null, organization: { status: "ACTIVE" }, }, select: { organization: { select: { slug: true, name: true } } }, }); if (intended === null) return "/admin?error=not_an_active_org_member"; const orgRoot = `/admin/org/${intended.organization.slug}`; // Default / missing returnTo sanitizes to "/admin". Always land in the org // admin SPA (not the legacy static "close this tab" complete page). if (returnTo === "/admin") { return orgRoot; } return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot; } 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"; } function setOAuthNonceCookie(reply: FastifyReply, nonce: string, secure: boolean): void { // Signed state carries scope/returnTo; the cookie mirrors nonce for double-submit CSRF. reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, { path: "/", httpOnly: true, sameSite: "lax", secure, maxAge: 600, }); } /** * 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('"', """); }