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:
@@ -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