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
+124
View File
@@ -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 };
}