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:
2026-07-10 00:55:19 +08:00
parent 87e3d3f990
commit c4f052efa2
12 changed files with 1297 additions and 2 deletions
+96
View File
@@ -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/);
});
});
+78
View File
@@ -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");
});
});