forked from bai/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.
|
||||
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` →
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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