/** * Feishu web OAuth (user login) for org admin panel. * * Organization login resolves the intended encrypted Feishu Application * Connection before building the authorize URL or exchanging the code. * * 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 */ import { classifyNetworkFailure, type NetworkFailureCategory } from "../../connections/networkFailure.js"; 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 unionId?: string | undefined; readonly displayName: string; readonly avatarUrl: string | null; } export class FeishuOAuthError extends Error { constructor( readonly code: "feishu_oauth_unreachable" | "feishu_oauth_rejected" | "feishu_oauth_invalid_response", message: string, readonly category: NetworkFailureCategory | "http" | "provider_rejection" | "invalid_response", readonly upstreamStatus?: number, ) { super(message); this.name = "FeishuOAuthError"; } } 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 { const accessToken = await exchangeCodeForAccessToken(config, code); return fetchUserInfo(config, accessToken); } async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise { const fetchImpl = config.fetchImpl ?? fetch; const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL; let res: Response; try { 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, }), redirect: "manual", signal: AbortSignal.timeout(10_000), }); } catch (error) { throw new FeishuOAuthError( "feishu_oauth_unreachable", "Feishu OAuth token exchange could not reach provider", classifyNetworkFailure(error), ); } const body = await oauthJson(res, "token exchange"); if (!res.ok || body["code"] !== 0 || typeof body["access_token"] !== "string" || body["access_token"] === "") { throw new FeishuOAuthError( "feishu_oauth_rejected", "Feishu OAuth token exchange was rejected", res.ok ? "provider_rejection" : "http", res.status, ); } return body["access_token"]; } async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise { const fetchImpl = config.fetchImpl ?? fetch; const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL; let res: Response; try { res = await fetchImpl(userInfoUrl, { method: "GET", headers: { Authorization: `Bearer ${userAccessToken}` }, redirect: "manual", signal: AbortSignal.timeout(10_000), }); } catch (error) { throw new FeishuOAuthError( "feishu_oauth_unreachable", "Feishu OAuth user info could not reach provider", classifyNetworkFailure(error), ); } const body = await oauthJson(res, "user info"); const data = body["data"]; if (!res.ok || body["code"] !== 0 || !isRecord(data)) { throw new FeishuOAuthError( "feishu_oauth_rejected", "Feishu OAuth user info was rejected", res.ok ? "provider_rejection" : "http", res.status, ); } const openId = data["open_id"]; if (typeof openId !== "string" || openId === "") { throw new FeishuOAuthError( "feishu_oauth_invalid_response", "Feishu OAuth user info response is missing open_id", "invalid_response", res.status, ); } const displayName = (typeof data["name"] === "string" && data["name"] !== "" ? data["name"] : null) ?? (typeof data["en_name"] === "string" && data["en_name"] !== "" ? data["en_name"] : null) ?? openId; const avatar = (typeof data["avatar_url"] === "string" && data["avatar_url"] !== "" ? data["avatar_url"] : null) ?? (typeof data["avatar_middle"] === "string" && data["avatar_middle"] !== "" ? data["avatar_middle"] : null); const unionId = typeof data["union_id"] === "string" && data["union_id"] !== "" ? data["union_id"] : undefined; return { openId, ...(unionId !== undefined ? { unionId } : {}), displayName, avatarUrl: avatar }; } async function oauthJson(response: Response, operation: string): Promise> { let value: unknown; try { value = await response.json(); } catch { throw new FeishuOAuthError( "feishu_oauth_invalid_response", `Feishu OAuth ${operation} returned an invalid response`, "invalid_response", response.status, ); } if (!isRecord(value)) { throw new FeishuOAuthError( "feishu_oauth_invalid_response", `Feishu OAuth ${operation} returned an invalid response`, "invalid_response", response.status, ); } return value; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }