feat: add deployable alpha silo

This commit is contained in:
2026-07-11 00:25:45 +08:00
parent 44557da499
commit 9e954790dc
57 changed files with 2792 additions and 400 deletions
+118 -51
View File
@@ -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);
}
+41 -1
View File
@@ -16,6 +16,8 @@ export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADM
export interface AuthContext {
readonly session: SessionPayload;
readonly user: User;
readonly feishuOpenId: string;
readonly authenticationOrganizationId?: string | undefined;
}
export interface OrgAuthContext extends AuthContext {
@@ -55,12 +57,45 @@ export async function requireSession(
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
return null;
}
if ("feishuIdentityId" in session) {
const identity = await deps.prisma.feishuUserIdentity.findUnique({
where: { id: session.feishuIdentityId },
include: {
user: true,
connection: {
select: {
id: true,
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
organization: { select: { id: true, status: true } },
},
},
},
});
if (identity === null || identity.userId !== session.userId ||
identity.connectionId !== session.feishuConnectionId ||
identity.connection.organization.id !== session.feishuOrganizationId ||
identity.connection.status !== "ACTIVE" ||
identity.connection.activeSecretVersion === null ||
identity.connection.activeSecretVersion.connectionId !== identity.connection.id ||
identity.connection.activeSecretVersion.retiredAt !== null ||
identity.connection.organization.status !== "ACTIVE") {
await sendError(reply, 401, "unauthenticated", "session identity not found or inactive");
return null;
}
return {
session,
user: identity.user,
feishuOpenId: identity.openId,
authenticationOrganizationId: identity.connection.organization.id,
};
}
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
await sendError(reply, 401, "unauthenticated", "session user not found");
return null;
}
return { session, user };
return { session, user, feishuOpenId: user.feishuOpenId };
}
export async function requireOrgRole(
@@ -87,6 +122,11 @@ export async function requireOrgRole(
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
return null;
}
if (auth.authenticationOrganizationId !== undefined &&
auth.authenticationOrganizationId !== organization.id) {
await sendError(reply, 403, "forbidden", "session is scoped to a different organization");
return null;
}
const membership = await deps.prisma.organizationMembership.findFirst({
where: {
+60 -15
View File
@@ -15,28 +15,41 @@ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
/** OAuth state cookie TTL: 10 minutes. */
export const OAUTH_STATE_TTL_SECONDS = 10 * 60;
export interface SessionPayload {
readonly userId: string;
readonly feishuOpenId: string;
interface SessionTimes {
readonly iat: number;
readonly exp: number;
}
export type SessionIdentity =
| {
readonly userId: string;
readonly feishuOpenId: string;
}
| {
readonly userId: string;
readonly feishuIdentityId: string;
readonly feishuConnectionId: string;
readonly feishuOrganizationId: string;
};
export type SessionPayload = SessionIdentity & SessionTimes;
export interface OAuthStatePayload {
readonly nonce: string;
readonly returnTo: string;
readonly organizationId?: string | undefined;
readonly connectionId?: string | undefined;
readonly exp: number;
}
export function signSession(
payload: Omit<SessionPayload, "iat" | "exp">,
payload: SessionIdentity,
secret: string,
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: SessionPayload = {
userId: payload.userId,
feishuOpenId: payload.feishuOpenId,
...payload,
iat: nowSeconds,
exp: nowSeconds + ttlSeconds,
};
@@ -48,15 +61,38 @@ export function verifySession(
secret: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
): SessionPayload | null {
const payload = verifyJson<SessionPayload>(token, secret);
const payload = verifyJson<Record<string, unknown>>(token, secret);
if (payload === null) return null;
if (typeof payload.userId !== "string" || payload.userId === "") return null;
if (typeof payload.feishuOpenId !== "string" || payload.feishuOpenId === "") return null;
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
if (payload.exp <= nowSeconds) return null;
return payload;
if (typeof payload["userId"] !== "string" || payload["userId"] === "") return null;
if (typeof payload["iat"] !== "number" || typeof payload["exp"] !== "number") return null;
if (payload["exp"] <= nowSeconds) return null;
const legacy = typeof payload["feishuOpenId"] === "string" && payload["feishuOpenId"] !== "";
const scoped = typeof payload["feishuIdentityId"] === "string" && payload["feishuIdentityId"] !== "" &&
typeof payload["feishuConnectionId"] === "string" && payload["feishuConnectionId"] !== "" &&
typeof payload["feishuOrganizationId"] === "string" && payload["feishuOrganizationId"] !== "";
if (legacy === scoped) return null;
if (legacy) {
return {
userId: payload["userId"],
feishuOpenId: payload["feishuOpenId"] as string,
iat: payload["iat"],
exp: payload["exp"],
};
}
return {
userId: payload["userId"],
feishuIdentityId: payload["feishuIdentityId"] as string,
feishuConnectionId: payload["feishuConnectionId"] as string,
feishuOrganizationId: payload["feishuOrganizationId"] as string,
iat: payload["iat"],
exp: payload["exp"],
};
}
/*
* OAuth state may be legacy/unscoped or bind both an Organization and its
* intended Feishu connection. A half-scoped state is always invalid.
*/
export function signOAuthState(
payload: Omit<OAuthStatePayload, "exp">,
secret: string,
@@ -64,8 +100,7 @@ export function signOAuthState(
nowSeconds: number = Math.floor(Date.now() / 1000),
): string {
const full: OAuthStatePayload = {
nonce: payload.nonce,
returnTo: payload.returnTo,
...payload,
exp: nowSeconds + ttlSeconds,
};
return signJson(full, secret);
@@ -81,7 +116,17 @@ export function verifyOAuthState(
if (typeof payload.nonce !== "string" || payload.nonce === "") return null;
if (typeof payload.returnTo !== "string") return null;
if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null;
return payload;
const hasOrganization = typeof payload.organizationId === "string" && payload.organizationId !== "";
const hasConnection = typeof payload.connectionId === "string" && payload.connectionId !== "";
if (hasOrganization !== hasConnection) return null;
return {
nonce: payload.nonce,
returnTo: payload.returnTo,
...(hasOrganization
? { organizationId: payload.organizationId!, connectionId: payload.connectionId! }
: {}),
exp: payload.exp,
};
}
function signJson(value: unknown, secret: string): string {