forked from EduCraft/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* 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).
|
||||
* 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;
|
||||
@@ -24,10 +26,23 @@ export interface FeishuOAuthConfig {
|
||||
|
||||
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";
|
||||
@@ -59,66 +74,118 @@ export async function exchangeCodeForUser(
|
||||
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}`);
|
||||
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),
|
||||
);
|
||||
}
|
||||
return body.access_token;
|
||||
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;
|
||||
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}`}`);
|
||||
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 openId = body.data.open_id;
|
||||
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 Error("Feishu user_info response missing open_id");
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_invalid_response",
|
||||
"Feishu OAuth user info response is missing open_id",
|
||||
"invalid_response",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
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) ??
|
||||
(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 body.data.avatar_url === "string" && body.data.avatar_url !== ""
|
||||
? body.data.avatar_url
|
||||
(typeof data["avatar_url"] === "string" && data["avatar_url"] !== ""
|
||||
? data["avatar_url"]
|
||||
: null) ??
|
||||
(typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== ""
|
||||
? body.data.avatar_middle
|
||||
(typeof data["avatar_middle"] === "string" && data["avatar_middle"] !== ""
|
||||
? data["avatar_middle"]
|
||||
: null);
|
||||
return { openId, displayName, avatarUrl: avatar };
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user