diff --git a/hub/.env.example b/hub/.env.example index 5d4517c..c1da809 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -34,3 +34,18 @@ FEISHU_BOT_OPEN_ID="" # Hub server port. Defaults to 8788. PORT=8788 + +# --- Org admin web (ADR-0021) --------------------------------------------- +# Public base URL of this Hub (no trailing slash). Used for Feishu OAuth +# redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback +# Configure the same callback URL in the Feishu developer console under +# Security Settings → Redirect URLs. +HUB_PUBLIC_BASE_URL="http://127.0.0.1:8788" + +# HMAC secret for signed session + OAuth state cookies. Generate with: +# openssl rand -base64 32 +HUB_SESSION_SECRET="" + +# Optional OAuth scope (space-separated). Default: contact:user.base:readonly +# Apply matching scopes in the Feishu developer console. +# HUB_OAUTH_SCOPE="contact:user.base:readonly" diff --git a/hub/src/admin/auth/feishuOAuth.ts b/hub/src/admin/auth/feishuOAuth.ts new file mode 100644 index 0000000..83cfdfe --- /dev/null +++ b/hub/src/admin/auth/feishuOAuth.ts @@ -0,0 +1,124 @@ +/** + * Feishu web OAuth (user login) for org admin panel. + * + * Pilot uses the process-global FEISHU_APP_ID/SECRET (same app as the bot). + * Per-org encrypted Feishu app secrets are deferred (ADR-0021). + * + * Flow: + * 1. GET authorize URL → user consents + * 2. callback code → user_access_token (authen/v2/oauth/token) + * 3. user_access_token → user info (authen/v1/user_info) → open_id + */ + +export interface FeishuOAuthConfig { + readonly appId: string; + readonly appSecret: string; + readonly redirectUri: string; + /** Space-separated scopes requested at authorize time. */ + readonly scope: string; + readonly authorizeBaseUrl?: string; + readonly tokenUrl?: string; + readonly userInfoUrl?: string; + readonly fetchImpl?: typeof fetch; +} + +export interface FeishuOAuthUser { + readonly openId: string; + readonly displayName: string; + readonly avatarUrl: string | null; +} + +const DEFAULT_AUTHORIZE_BASE = "https://accounts.feishu.cn/open-apis/authen/v1/authorize"; +const DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token"; +const DEFAULT_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info"; + +/** Minimal scopes for open_id + name/avatar on user_info. */ +export const DEFAULT_OAUTH_SCOPE = "contact:user.base:readonly"; + +export function buildAuthorizeUrl(config: FeishuOAuthConfig, state: string): string { + const base = config.authorizeBaseUrl ?? DEFAULT_AUTHORIZE_BASE; + const url = new URL(base); + url.searchParams.set("client_id", config.appId); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", config.redirectUri); + url.searchParams.set("state", state); + if (config.scope.trim() !== "") { + url.searchParams.set("scope", config.scope); + } + return url.toString(); +} + +export async function exchangeCodeForUser( + config: FeishuOAuthConfig, + code: string, +): Promise { + const accessToken = await exchangeCodeForAccessToken(config, code); + return fetchUserInfo(config, accessToken); +} + +async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise { + const fetchImpl = config.fetchImpl ?? fetch; + const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL; + const res = await fetchImpl(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: config.appId, + client_secret: config.appSecret, + code, + redirect_uri: config.redirectUri, + }), + }); + const body = (await res.json()) as { + code?: number; + access_token?: string; + error?: string; + error_description?: string; + msg?: string; + }; + if (!res.ok || body.code !== 0 || typeof body.access_token !== "string" || body.access_token === "") { + const detail = body.error_description ?? body.error ?? body.msg ?? `HTTP ${res.status}`; + throw new Error(`Feishu OAuth token exchange failed: ${detail}`); + } + return body.access_token; +} + +async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise { + const fetchImpl = config.fetchImpl ?? fetch; + const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL; + const res = await fetchImpl(userInfoUrl, { + method: "GET", + headers: { Authorization: `Bearer ${userAccessToken}` }, + }); + const body = (await res.json()) as { + code?: number; + msg?: string; + data?: { + open_id?: string; + name?: string; + en_name?: string; + avatar_url?: string; + avatar_middle?: string; + }; + }; + if (!res.ok || body.code !== 0 || body.data === undefined) { + throw new Error(`Feishu user_info failed: ${body.msg ?? `HTTP ${res.status}`}`); + } + const openId = body.data.open_id; + if (typeof openId !== "string" || openId === "") { + throw new Error("Feishu user_info response missing open_id"); + } + const displayName = + (typeof body.data.name === "string" && body.data.name !== "" ? body.data.name : null) ?? + (typeof body.data.en_name === "string" && body.data.en_name !== "" ? body.data.en_name : null) ?? + openId; + const avatar = + (typeof body.data.avatar_url === "string" && body.data.avatar_url !== "" + ? body.data.avatar_url + : null) ?? + (typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== "" + ? body.data.avatar_middle + : null); + return { openId, displayName, avatarUrl: avatar }; +} diff --git a/hub/src/admin/auth/guards.ts b/hub/src/admin/auth/guards.ts new file mode 100644 index 0000000..0557571 --- /dev/null +++ b/hub/src/admin/auth/guards.ts @@ -0,0 +1,137 @@ +/** + * HTTP guards for org admin APIs (ADR-0021). + * + * Platform admin is a separate control plane — not modeled here. + */ +import type { Organization, OrganizationMemberRole, PrismaClient, User } from "@prisma/client"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { + SESSION_COOKIE_NAME, + verifySession, + type SessionPayload, +} from "./session.js"; + +export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"]; + +export interface AuthContext { + readonly session: SessionPayload; + readonly user: User; +} + +export interface OrgAuthContext extends AuthContext { + readonly organization: Organization; + readonly membershipRole: OrganizationMemberRole; +} + +export interface GuardDeps { + readonly prisma: PrismaClient; + readonly sessionSecret: string; +} + +export class HttpError extends Error { + readonly statusCode: number; + readonly code: string; + + constructor(statusCode: number, code: string, message: string) { + super(message); + this.name = "HttpError"; + this.statusCode = statusCode; + this.code = code; + } +} + +export async function requireSession( + request: FastifyRequest, + reply: FastifyReply, + deps: GuardDeps, +): Promise { + const raw = request.cookies[SESSION_COOKIE_NAME]; + if (raw === undefined || raw === "") { + await sendError(reply, 401, "unauthenticated", "login required"); + return null; + } + const session = verifySession(raw, deps.sessionSecret); + if (session === null) { + await sendError(reply, 401, "unauthenticated", "session invalid or expired"); + return null; + } + const user = await deps.prisma.user.findUnique({ where: { id: session.userId } }); + if (user === null || user.feishuOpenId !== session.feishuOpenId) { + await sendError(reply, 401, "unauthenticated", "session user not found"); + return null; + } + return { session, user }; +} + +export async function requireOrgRole( + request: FastifyRequest, + reply: FastifyReply, + deps: GuardDeps, + options: { + readonly orgSlug: string; + readonly roles?: readonly OrganizationMemberRole[]; + }, +): Promise { + const auth = await requireSession(request, reply, deps); + if (auth === null) return null; + + const allowed = options.roles ?? ORG_ADMIN_ROLES; + const organization = await deps.prisma.organization.findUnique({ + where: { slug: options.orgSlug }, + }); + if (organization === null) { + await sendError(reply, 404, "org_not_found", `organization not found: ${options.orgSlug}`); + return null; + } + if (organization.status !== "ACTIVE") { + await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`); + return null; + } + + const membership = await deps.prisma.organizationMembership.findFirst({ + where: { + organizationId: organization.id, + userId: auth.user.id, + revokedAt: null, + }, + select: { role: true }, + }); + if (membership === null) { + await sendError(reply, 403, "forbidden", "not a member of this organization"); + return null; + } + if (!allowed.includes(membership.role)) { + await sendError(reply, 403, "forbidden", `requires role: ${allowed.join("|")}`); + return null; + } + + return { + ...auth, + organization, + membershipRole: membership.role, + }; +} + +export async function requireOrgProject( + deps: GuardDeps, + organizationId: string, + projectId: string, +): Promise<{ readonly id: string; readonly organizationId: string; readonly name: string }> { + const project = await deps.prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true, organizationId: true, name: true, archivedAt: true }, + }); + if (project === null || project.organizationId !== organizationId) { + throw new HttpError(404, "project_not_found", `project not found: ${projectId}`); + } + return project; +} + +export async function sendError( + reply: FastifyReply, + statusCode: number, + code: string, + message: string, +): Promise { + await reply.status(statusCode).send({ error: { code, message } }); +} diff --git a/hub/src/admin/auth/session.ts b/hub/src/admin/auth/session.ts new file mode 100644 index 0000000..a248511 --- /dev/null +++ b/hub/src/admin/auth/session.ts @@ -0,0 +1,116 @@ +/** + * Signed cookie session for org admin web login (ADR-0021). + * + * Payload is HMAC-SHA256 signed (HUB_SESSION_SECRET). No server-side session + * table in v1 — logout clears the cookie; stolen-cookie revoke is deferred. + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const SESSION_COOKIE_NAME = "cph_session"; +export const OAUTH_STATE_COOKIE_NAME = "cph_oauth_state"; + +/** Default session TTL: 7 days. */ +export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; + +/** OAuth state cookie TTL: 10 minutes. */ +export const OAUTH_STATE_TTL_SECONDS = 10 * 60; + +export interface SessionPayload { + readonly userId: string; + readonly feishuOpenId: string; + readonly iat: number; + readonly exp: number; +} + +export interface OAuthStatePayload { + readonly nonce: string; + readonly returnTo: string; + readonly exp: number; +} + +export function signSession( + payload: Omit, + secret: string, + ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS, + nowSeconds: number = Math.floor(Date.now() / 1000), +): string { + const full: SessionPayload = { + userId: payload.userId, + feishuOpenId: payload.feishuOpenId, + iat: nowSeconds, + exp: nowSeconds + ttlSeconds, + }; + return signJson(full, secret); +} + +export function verifySession( + token: string, + secret: string, + nowSeconds: number = Math.floor(Date.now() / 1000), +): SessionPayload | null { + const payload = verifyJson(token, secret); + if (payload === null) return null; + if (typeof payload.userId !== "string" || payload.userId === "") return null; + if (typeof payload.feishuOpenId !== "string" || payload.feishuOpenId === "") return null; + if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null; + if (payload.exp <= nowSeconds) return null; + return payload; +} + +export function signOAuthState( + payload: Omit, + secret: string, + ttlSeconds: number = OAUTH_STATE_TTL_SECONDS, + nowSeconds: number = Math.floor(Date.now() / 1000), +): string { + const full: OAuthStatePayload = { + nonce: payload.nonce, + returnTo: payload.returnTo, + exp: nowSeconds + ttlSeconds, + }; + return signJson(full, secret); +} + +export function verifyOAuthState( + token: string, + secret: string, + nowSeconds: number = Math.floor(Date.now() / 1000), +): OAuthStatePayload | null { + const payload = verifyJson(token, secret); + if (payload === null) return null; + if (typeof payload.nonce !== "string" || payload.nonce === "") return null; + if (typeof payload.returnTo !== "string") return null; + if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null; + return payload; +} + +function signJson(value: unknown, secret: string): string { + const body = Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + const sig = hmac(body, secret); + return `${body}.${sig}`; +} + +function verifyJson(token: string, secret: string): T | null { + const dot = token.indexOf("."); + if (dot <= 0 || dot === token.length - 1) return null; + const body = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = hmac(body, secret); + if (!safeEqual(sig, expected)) return null; + try { + return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T; + } catch { + return null; + } +} + +function hmac(body: string, secret: string): string { + return createHmac("sha256", secret).update(body).digest("base64url"); +} + +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a); + const bb = Buffer.from(b); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} diff --git a/hub/src/admin/errors.ts b/hub/src/admin/errors.ts new file mode 100644 index 0000000..931e36c --- /dev/null +++ b/hub/src/admin/errors.ts @@ -0,0 +1,47 @@ +/** + * Map domain / HTTP errors to consistent JSON responses. + */ +import type { FastifyReply } from "fastify"; +import { HttpError, sendError } from "./auth/guards.js"; + +export async function handleRouteError(reply: FastifyReply, err: unknown): Promise { + if (err instanceof HttpError) { + await sendError(reply, err.statusCode, err.code, err.message); + return; + } + if (err instanceof Error) { + const mapped = mapDomainError(err.message); + if (mapped !== null) { + await sendError(reply, mapped.statusCode, mapped.code, mapped.message); + return; + } + await sendError(reply, 500, "internal_error", "internal error"); + return; + } + await sendError(reply, 500, "internal_error", "internal error"); +} + +function mapDomainError(message: string): { statusCode: number; code: string; message: string } | null { + const lower = message.toLowerCase(); + if (lower.includes("not found")) { + return { statusCode: 404, code: "not_found", message }; + } + if ( + lower.includes("requires") || + lower.includes("forbidden") || + lower.includes("cannot") || + lower.includes("not an active member") || + lower.includes("refused") + ) { + return { statusCode: 403, code: "forbidden", message }; + } + if ( + lower.includes("is required") || + lower.includes("already") || + lower.includes("invalid") || + lower.includes("accepts only") + ) { + return { statusCode: 400, code: "bad_request", message }; + } + return null; +} diff --git a/hub/src/admin/plugin.ts b/hub/src/admin/plugin.ts new file mode 100644 index 0000000..441ca91 --- /dev/null +++ b/hub/src/admin/plugin.ts @@ -0,0 +1,47 @@ +/** + * Registers org-admin HTTP surface: auth, org APIs, (later) static SPA. + */ +import cookie from "@fastify/cookie"; +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { registerAuthRoutes } from "./routes/authRoutes.js"; +import { registerOrgRoutes } from "./routes/orgRoutes.js"; + +export interface AdminPluginConfig { + readonly prisma: PrismaClient; + readonly sessionSecret: string; + readonly publicBaseUrl: string; + readonly feishuAppId: string; + readonly feishuAppSecret: string; + readonly projectWorkspaceRoot: string; + readonly cookieSecure?: boolean; + readonly oauthScope?: string; + readonly fetchImpl?: typeof fetch; +} + +export async function registerAdminPlugin( + app: FastifyInstance, + config: AdminPluginConfig, +): Promise { + await app.register(cookie); + + const cookieSecure = + config.cookieSecure ?? config.publicBaseUrl.startsWith("https://"); + + await registerAuthRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + publicBaseUrl: config.publicBaseUrl, + feishuAppId: config.feishuAppId, + feishuAppSecret: config.feishuAppSecret, + cookieSecure, + ...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}), + ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), + }); + + await registerOrgRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + projectWorkspaceRoot: config.projectWorkspaceRoot, + }); +} diff --git a/hub/src/admin/routes/authRoutes.ts b/hub/src/admin/routes/authRoutes.ts new file mode 100644 index 0000000..c9db9ad --- /dev/null +++ b/hub/src/admin/routes/authRoutes.ts @@ -0,0 +1,293 @@ +/** + * 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('"', """); +} diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts new file mode 100644 index 0000000..bf8163d --- /dev/null +++ b/hub/src/admin/routes/orgRoutes.ts @@ -0,0 +1,77 @@ +/** + * Mount point for `/api/org/:orgSlug/*` org-admin APIs. + */ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; +import { + ensureOrganizationProjectSettings, + setMembersCanCreateProjects, +} from "../../projectOnboarding.js"; + +export interface OrgRouteConfig { + readonly prisma: PrismaClient; + readonly sessionSecret: string; + readonly projectWorkspaceRoot: string; +} + +export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return { + organization: { + id: auth.organization.id, + slug: auth.organization.slug, + name: auth.organization.name, + status: auth.organization.status, + }, + actorRole: auth.membershipRole, + }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/settings", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const settings = await ensureOrganizationProjectSettings(config.prisma, auth.organization.id); + return { + membersCanCreateProjects: settings.membersCanCreateProjects, + }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.patch("/api/org/:orgSlug/settings", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { membersCanCreateProjects?: unknown }; + if (typeof body.membersCanCreateProjects !== "boolean") { + return reply.status(400).send({ + error: { code: "bad_request", message: "membersCanCreateProjects must be a boolean" }, + }); + } + const settings = await setMembersCanCreateProjects(config.prisma, { + organizationId: auth.organization.id, + enabled: body.membersCanCreateProjects, + }); + return { + membersCanCreateProjects: settings.membersCanCreateProjects, + }; + } catch (err) { + return handleRouteError(reply, err); + } + }); +} diff --git a/hub/src/server.ts b/hub/src/server.ts index e3da3af..8104d79 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -1,5 +1,5 @@ /** - * Hub entry — Fastify server + Feishu WebSocket listener. + * Hub entry — Fastify server + Feishu WebSocket listener + org admin HTTP. * * Wires the full trigger path: lark ws receives `im.message.receive_v1` → * trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires @@ -7,11 +7,15 @@ * (ADR-0017), and releases the lock on finish. Feishu context reads inside the * loop are ADR-0003-authorized (chat must match binding). * + * Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`. + * * Env (see .env.example): - * DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT + * DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT, + * HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL */ import "dotenv/config"; import Fastify from "fastify"; +import { registerAdminPlugin } from "./admin/plugin.js"; import { prisma } from "./db.js"; import { createEnvRuntimeSettings } from "./settings/runtime.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; @@ -44,6 +48,8 @@ async function main(): Promise { const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT"); + const sessionSecret = requireEnv("HUB_SESSION_SECRET"); + const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788"; const port = Number(process.env["PORT"] ?? "8788"); const app = Fastify({ logger: true }); @@ -58,6 +64,15 @@ async function main(): Promise { app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); + await registerAdminPlugin(app, { + prisma, + sessionSecret, + publicBaseUrl, + feishuAppId, + feishuAppSecret, + projectWorkspaceRoot, + }); + // --- Feishu listener --- const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); if (feishuListenerEnabled) { diff --git a/hub/test/integration/admin-auth.test.ts b/hub/test/integration/admin-auth.test.ts new file mode 100644 index 0000000..45fca9e --- /dev/null +++ b/hub/test/integration/admin-auth.test.ts @@ -0,0 +1,250 @@ +/** + * Org admin auth + requireOrgRole integration tests (ADR-0021). + */ +import Fastify from "fastify"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAdminPlugin } from "../../src/admin/plugin.js"; +import { + mintSessionToken, + sessionCookieHeader, +} from "../../src/admin/routes/authRoutes.js"; +import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js"; +import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; + +const SESSION_SECRET = "integration-test-session-secret"; +const PUBLIC_BASE = "http://127.0.0.1:8788"; + +async function buildApp(fetchImpl?: typeof fetch) { + const app = Fastify({ logger: false }); + await registerAdminPlugin(app, { + prisma, + sessionSecret: SESSION_SECRET, + publicBaseUrl: PUBLIC_BASE, + feishuAppId: "cli_test", + feishuAppSecret: "secret_test", + projectWorkspaceRoot: "/tmp/cph-test-workspaces", + cookieSecure: false, + ...(fetchImpl !== undefined ? { fetchImpl } : {}), + }); + await app.ready(); + return app; +} + +async function seedUser( + id: string, + openId: string, + role: "OWNER" | "ADMIN" | "MEMBER" = "ADMIN", + orgId: string = DEFAULT_ORG_ID, +): Promise { + await prisma.user.create({ + data: { id, feishuOpenId: openId, displayName: id }, + }); + await prisma.organizationMembership.create({ + data: { organizationId: orgId, userId: id, role }, + }); +} + +describe("admin auth + org API guards", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("GET /api/me returns 401 without session", async () => { + const app = await buildApp(); + try { + const res = await app.inject({ method: "GET", url: "/api/me" }); + expect(res.statusCode).toBe(401); + expect(res.json().error.code).toBe("unauthenticated"); + } finally { + await app.close(); + } + }); + + it("GET /api/me returns user and memberships with valid session", async () => { + await seedUser("u-admin", "ou_admin", "ADMIN"); + const app = await buildApp(); + try { + const token = mintSessionToken( + { userId: "u-admin", feishuOpenId: "ou_admin" }, + SESSION_SECRET, + ); + const res = await app.inject({ + method: "GET", + url: "/api/me", + headers: { cookie: sessionCookieHeader(token) }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { + user: { id: string; feishuOpenId: string }; + organizations: Array<{ slug: string; role: string }>; + }; + expect(body.user.id).toBe("u-admin"); + expect(body.user.feishuOpenId).toBe("ou_admin"); + expect(body.organizations).toEqual([ + expect.objectContaining({ slug: "test-default", role: "ADMIN" }), + ]); + } finally { + await app.close(); + } + }); + + it("org summary requires OWNER or ADMIN", async () => { + await seedUser("u-member", "ou_member", "MEMBER"); + await seedUser("u-admin", "ou_admin", "ADMIN"); + const app = await buildApp(); + try { + const memberToken = mintSessionToken( + { userId: "u-member", feishuOpenId: "ou_member" }, + SESSION_SECRET, + ); + const memberRes = await app.inject({ + method: "GET", + url: "/api/org/test-default", + headers: { cookie: sessionCookieHeader(memberToken) }, + }); + expect(memberRes.statusCode).toBe(403); + + const adminToken = mintSessionToken( + { userId: "u-admin", feishuOpenId: "ou_admin" }, + SESSION_SECRET, + ); + const adminRes = await app.inject({ + method: "GET", + url: "/api/org/test-default", + headers: { cookie: sessionCookieHeader(adminToken) }, + }); + expect(adminRes.statusCode).toBe(200); + expect(adminRes.json()).toEqual({ + organization: expect.objectContaining({ + id: DEFAULT_ORG_ID, + slug: "test-default", + }), + actorRole: "ADMIN", + }); + } finally { + await app.close(); + } + }); + + it("PATCH settings updates membersCanCreateProjects", async () => { + await seedUser("u-owner", "ou_owner", "OWNER"); + const app = await buildApp(); + try { + const token = mintSessionToken( + { userId: "u-owner", feishuOpenId: "ou_owner" }, + SESSION_SECRET, + ); + const res = await app.inject({ + method: "PATCH", + url: "/api/org/test-default/settings", + headers: { + cookie: sessionCookieHeader(token), + "content-type": "application/json", + }, + payload: { membersCanCreateProjects: false }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ membersCanCreateProjects: false }); + + const getRes = await app.inject({ + method: "GET", + url: "/api/org/test-default/settings", + headers: { cookie: sessionCookieHeader(token) }, + }); + expect(getRes.json()).toEqual({ membersCanCreateProjects: false }); + } finally { + await app.close(); + } + }); + + it("GET /auth/feishu redirects to Feishu authorize URL", async () => { + const app = await buildApp(); + try { + const res = await app.inject({ + method: "GET", + url: "/auth/feishu?returnTo=/admin/org/test-default", + }); + expect(res.statusCode).toBe(302); + const location = res.headers.location; + expect(location).toBeTypeOf("string"); + const url = new URL(location as string); + expect(url.hostname).toBe("accounts.feishu.cn"); + expect(url.searchParams.get("client_id")).toBe("cli_test"); + expect(url.searchParams.get("redirect_uri")).toBe( + `${PUBLIC_BASE}/auth/feishu/callback`, + ); + expect(url.searchParams.get("state")).toBeTruthy(); + const setCookie = res.headers["set-cookie"]; + expect(JSON.stringify(setCookie)).toContain(OAUTH_STATE_COOKIE_NAME); + } finally { + await app.close(); + } + }); + + it("OAuth callback upserts user and sets session cookie", async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/oauth/token")) { + return new Response( + JSON.stringify({ code: 0, access_token: "u-tok", token_type: "Bearer", expires_in: 7200 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.includes("/user_info")) { + return new Response( + JSON.stringify({ + code: 0, + data: { open_id: "ou_new", name: "New User", avatar_url: null }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`unexpected ${url}`); + }); + + const app = await buildApp(fetchImpl as unknown as typeof fetch); + try { + const nonce = "nonce-test-1"; + const state = signOAuthState( + { nonce, returnTo: "/admin/org/test-default" }, + SESSION_SECRET, + ); + const res = await app.inject({ + method: "GET", + url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state)}`, + headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` }, + }); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe("/admin/org/test-default"); + expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session="); + + const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } }); + expect(user?.displayName).toBe("New User"); + } finally { + await app.close(); + } + }); + + it("unknown org slug returns 404 for admin", async () => { + await seedUser("u-admin", "ou_admin", "ADMIN"); + const app = await buildApp(); + try { + const token = mintSessionToken( + { userId: "u-admin", feishuOpenId: "ou_admin" }, + SESSION_SECRET, + ); + const res = await app.inject({ + method: "GET", + url: "/api/org/does-not-exist", + headers: { cookie: sessionCookieHeader(token) }, + }); + expect(res.statusCode).toBe(404); + } finally { + await app.close(); + } + }); +}); diff --git a/hub/test/unit/feishu-oauth.test.ts b/hub/test/unit/feishu-oauth.test.ts new file mode 100644 index 0000000..c269165 --- /dev/null +++ b/hub/test/unit/feishu-oauth.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildAuthorizeUrl, + exchangeCodeForUser, + type FeishuOAuthConfig, +} from "../../src/admin/auth/feishuOAuth.js"; + +describe("buildAuthorizeUrl", () => { + it("includes client_id, redirect, state, and scope", () => { + const url = buildAuthorizeUrl( + { + appId: "cli_test", + appSecret: "secret", + redirectUri: "http://localhost:8788/auth/feishu/callback", + scope: "contact:user.base:readonly", + }, + "state-token", + ); + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe( + "https://accounts.feishu.cn/open-apis/authen/v1/authorize", + ); + expect(parsed.searchParams.get("client_id")).toBe("cli_test"); + expect(parsed.searchParams.get("response_type")).toBe("code"); + expect(parsed.searchParams.get("redirect_uri")).toBe( + "http://localhost:8788/auth/feishu/callback", + ); + expect(parsed.searchParams.get("state")).toBe("state-token"); + expect(parsed.searchParams.get("scope")).toBe("contact:user.base:readonly"); + }); +}); + +describe("exchangeCodeForUser", () => { + it("exchanges code then loads user_info", async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/oauth/token")) { + expect(init?.method).toBe("POST"); + const body = JSON.parse(String(init?.body)) as { code: string }; + expect(body.code).toBe("auth-code"); + return new Response( + JSON.stringify({ code: 0, access_token: "u-token", token_type: "Bearer", expires_in: 7200 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.includes("/user_info")) { + expect((init?.headers as Record).Authorization).toBe("Bearer u-token"); + return new Response( + JSON.stringify({ + code: 0, + data: { open_id: "ou_alice", name: "Alice", avatar_url: "https://img/a.png" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`unexpected url ${url}`); + }); + + const config: FeishuOAuthConfig = { + appId: "cli_test", + appSecret: "secret", + redirectUri: "http://localhost:8788/auth/feishu/callback", + scope: "contact:user.base:readonly", + fetchImpl: fetchImpl as unknown as typeof fetch, + }; + + const user = await exchangeCodeForUser(config, "auth-code"); + expect(user).toEqual({ + openId: "ou_alice", + displayName: "Alice", + avatarUrl: "https://img/a.png", + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("throws on token exchange failure", async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ code: 20003, error: "invalid_grant", error_description: "bad code" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await expect( + exchangeCodeForUser( + { + appId: "cli", + appSecret: "s", + redirectUri: "http://localhost/cb", + scope: "", + fetchImpl: fetchImpl as unknown as typeof fetch, + }, + "bad", + ), + ).rejects.toThrow(/token exchange failed/); + }); +}); diff --git a/hub/test/unit/session.test.ts b/hub/test/unit/session.test.ts new file mode 100644 index 0000000..28ca9d8 --- /dev/null +++ b/hub/test/unit/session.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + signOAuthState, + signSession, + verifyOAuthState, + verifySession, +} from "../../src/admin/auth/session.js"; +import { sanitizeReturnTo } from "../../src/admin/routes/authRoutes.js"; + +const SECRET = "test-session-secret-not-for-production"; + +describe("session cookie signing", () => { + it("round-trips a valid session", () => { + const token = signSession( + { userId: "u1", feishuOpenId: "ou_1" }, + SECRET, + 3600, + 1_700_000_000, + ); + const payload = verifySession(token, SECRET, 1_700_000_100); + expect(payload).toEqual({ + userId: "u1", + feishuOpenId: "ou_1", + iat: 1_700_000_000, + exp: 1_700_003_600, + }); + }); + + it("rejects tampered tokens", () => { + const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET); + const [body] = token.split("."); + expect(verifySession(`${body}.deadbeef`, SECRET)).toBeNull(); + }); + + it("rejects expired sessions", () => { + const token = signSession( + { userId: "u1", feishuOpenId: "ou_1" }, + SECRET, + 10, + 1_700_000_000, + ); + expect(verifySession(token, SECRET, 1_700_000_011)).toBeNull(); + }); + + it("rejects wrong secret", () => { + const token = signSession({ userId: "u1", feishuOpenId: "ou_1" }, SECRET); + expect(verifySession(token, "other-secret")).toBeNull(); + }); +}); + +describe("oauth state signing", () => { + it("round-trips state with returnTo", () => { + const token = signOAuthState( + { nonce: "abc", returnTo: "/admin/org/acme" }, + SECRET, + 600, + 1_700_000_000, + ); + expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({ + nonce: "abc", + returnTo: "/admin/org/acme", + exp: 1_700_000_600, + }); + }); +}); + +describe("sanitizeReturnTo", () => { + it("allows admin paths", () => { + expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin/org/acme"); + }); + + it("blocks open redirects", () => { + expect(sanitizeReturnTo("https://evil.example/")).toBe("/admin"); + expect(sanitizeReturnTo("//evil.example")).toBe("/admin"); + expect(sanitizeReturnTo("/api/me")).toBe("/admin"); + expect(sanitizeReturnTo("")).toBe("/admin"); + }); +});