forked from EduCraft/curriculum-project-hub
192 lines
6.4 KiB
TypeScript
192 lines
6.4 KiB
TypeScript
/**
|
|
* 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<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;
|
|
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<FeishuOAuthUser> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|