forked from EduCraft/curriculum-project-hub
feat(hub): add Feishu OAuth session for org admin
Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole guards, and the first org admin APIs (summary + project settings).
This commit is contained in:
@@ -34,3 +34,18 @@ FEISHU_BOT_OPEN_ID=""
|
|||||||
|
|
||||||
# Hub server port. Defaults to 8788.
|
# Hub server port. Defaults to 8788.
|
||||||
PORT=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"
|
||||||
|
|||||||
@@ -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<FeishuOAuthUser> {
|
||||||
|
const accessToken = await exchangeCodeForAccessToken(config, code);
|
||||||
|
return fetchUserInfo(config, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> {
|
||||||
|
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<FeishuOAuthUser> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<AuthContext | null> {
|
||||||
|
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<OrgAuthContext | null> {
|
||||||
|
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<void> {
|
||||||
|
await reply.status(statusCode).send({ error: { code, message } });
|
||||||
|
}
|
||||||
@@ -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<SessionPayload, "iat" | "exp">,
|
||||||
|
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<SessionPayload>(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<OAuthStatePayload, "exp">,
|
||||||
|
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<OAuthStatePayload>(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<T>(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);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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 !== ""
|
||||||
|
? `<p class="err">Login failed: ${escapeHtml(q.error)}</p>`
|
||||||
|
: "";
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>CPH Org Admin — Login</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
|
||||||
|
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:22rem;text-align:center}
|
||||||
|
a.btn{display:inline-block;margin-top:1rem;padding:.7rem 1.2rem;background:#3370ff;color:#fff;border-radius:8px;text-decoration:none;font-weight:600}
|
||||||
|
a.btn:hover{background:#245bdb}
|
||||||
|
.err{color:#c45656;font-size:.9rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Org Admin</h1>
|
||||||
|
<p>Sign in with Feishu to manage your organization.</p>
|
||||||
|
${errorHtml}
|
||||||
|
<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
return reply.type("text/html").send(html);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(
|
||||||
|
reply: FastifyReply,
|
||||||
|
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
|
||||||
|
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<string> {
|
||||||
|
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('"', """);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+17
-2
@@ -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` →
|
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
|
||||||
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
|
* 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
|
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
|
||||||
* loop are ADR-0003-authorized (chat must match binding).
|
* loop are ADR-0003-authorized (chat must match binding).
|
||||||
*
|
*
|
||||||
|
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
|
||||||
|
*
|
||||||
* Env (see .env.example):
|
* 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 "dotenv/config";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||||
@@ -44,6 +48,8 @@ async function main(): Promise<void> {
|
|||||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||||
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
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 port = Number(process.env["PORT"] ?? "8788");
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
@@ -58,6 +64,15 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||||
|
|
||||||
|
await registerAdminPlugin(app, {
|
||||||
|
prisma,
|
||||||
|
sessionSecret,
|
||||||
|
publicBaseUrl,
|
||||||
|
feishuAppId,
|
||||||
|
feishuAppSecret,
|
||||||
|
projectWorkspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
// --- Feishu listener ---
|
// --- Feishu listener ---
|
||||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||||
if (feishuListenerEnabled) {
|
if (feishuListenerEnabled) {
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>).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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user