forked from bai/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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AdminPluginConfig {
|
||||
readonly cookieSecure?: boolean;
|
||||
readonly oauthScope?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly allowLegacyFeishuOAuth?: boolean;
|
||||
}
|
||||
|
||||
export async function registerAdminPlugin(
|
||||
@@ -40,9 +41,13 @@ export async function registerAdminPlugin(
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
feishuAppId: config.feishuAppId,
|
||||
feishuAppSecret: config.feishuAppSecret,
|
||||
secretEnvelope: config.secretEnvelope,
|
||||
cookieSecure,
|
||||
...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}),
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
...(config.allowLegacyFeishuOAuth !== undefined
|
||||
? { allowLegacyFeishuOAuth: config.allowLegacyFeishuOAuth }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await registerOrgRoutes(app, {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js";
|
||||
import { upsertScopedFeishuIdentity } from "../../feishu/identityNamespace.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
DEFAULT_OAUTH_SCOPE,
|
||||
exchangeCodeForUser,
|
||||
FeishuOAuthError,
|
||||
type FeishuOAuthConfig,
|
||||
} from "../auth/feishuOAuth.js";
|
||||
import {
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
signOAuthState,
|
||||
signSession,
|
||||
verifyOAuthState,
|
||||
type SessionIdentity,
|
||||
} from "../auth/session.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
@@ -30,15 +35,17 @@ export interface AuthRouteConfig {
|
||||
readonly publicBaseUrl: string;
|
||||
readonly feishuAppId: string;
|
||||
readonly feishuAppSecret: string;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly oauthScope?: string;
|
||||
readonly cookieSecure: boolean;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly allowLegacyFeishuOAuth?: boolean;
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`;
|
||||
const oauthConfig: FeishuOAuthConfig = {
|
||||
const legacyOauthConfig: FeishuOAuthConfig = {
|
||||
appId: config.feishuAppId,
|
||||
appSecret: config.feishuAppSecret,
|
||||
redirectUri,
|
||||
@@ -46,25 +53,61 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
};
|
||||
|
||||
app.get("/auth/feishu", async (request, reply) => {
|
||||
if (config.allowLegacyFeishuOAuth === true) {
|
||||
app.get("/auth/feishu", async (request, reply) => {
|
||||
try {
|
||||
const returnTo = sanitizeReturnTo(
|
||||
typeof request.query === "object" && request.query !== null && "returnTo" in request.query
|
||||
? String((request.query as { returnTo?: string }).returnTo ?? "")
|
||||
: "",
|
||||
);
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
|
||||
setOAuthNonceCookie(reply, nonce, config.cookieSecure);
|
||||
const url = buildAuthorizeUrl(legacyOauthConfig, stateToken);
|
||||
return reply.redirect(url);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/auth/feishu/:orgSlug", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const organization = await config.prisma.organization.findUnique({
|
||||
where: { slug: orgSlug },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (organization === null) throw new HttpError(404, "org_not_found", `organization not found: ${orgSlug}`);
|
||||
if (organization.status !== "ACTIVE") {
|
||||
throw new HttpError(403, "org_not_active", `organization is ${organization.status}`);
|
||||
}
|
||||
const credential = await resolveActiveFeishuApplication(
|
||||
config.prisma,
|
||||
config.secretEnvelope,
|
||||
{ organizationId: organization.id },
|
||||
);
|
||||
const returnTo = sanitizeReturnTo(
|
||||
typeof request.query === "object" && request.query !== null && "returnTo" in request.query
|
||||
? String((request.query as { returnTo?: string }).returnTo ?? "")
|
||||
: "",
|
||||
);
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
|
||||
// state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit.
|
||||
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
maxAge: 600,
|
||||
});
|
||||
const url = buildAuthorizeUrl(oauthConfig, stateToken);
|
||||
return reply.redirect(url);
|
||||
const stateToken = signOAuthState({
|
||||
nonce,
|
||||
returnTo,
|
||||
organizationId: organization.id,
|
||||
connectionId: credential.connectionId,
|
||||
}, config.sessionSecret);
|
||||
setOAuthNonceCookie(reply, nonce, config.cookieSecure);
|
||||
return reply.redirect(buildAuthorizeUrl({
|
||||
appId: credential.appId,
|
||||
appSecret: credential.appSecret,
|
||||
redirectUri,
|
||||
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
}, stateToken));
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
@@ -96,33 +139,93 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
}
|
||||
reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" });
|
||||
|
||||
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
|
||||
const user = await config.prisma.user.upsert({
|
||||
where: { feishuOpenId: feishuUser.openId },
|
||||
create: {
|
||||
feishuOpenId: feishuUser.openId,
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
update: {
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
});
|
||||
let oauthConfig = legacyOauthConfig;
|
||||
if (statePayload.connectionId === undefined && config.allowLegacyFeishuOAuth !== true) {
|
||||
throw new HttpError(400, "bad_request", "unscoped Feishu OAuth is disabled");
|
||||
}
|
||||
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
|
||||
const credential = await resolveActiveFeishuApplication(
|
||||
config.prisma,
|
||||
config.secretEnvelope,
|
||||
{ connectionId: statePayload.connectionId },
|
||||
);
|
||||
if (credential.organizationId !== statePayload.organizationId) {
|
||||
throw new HttpError(400, "bad_request", "OAuth state Organization scope mismatch");
|
||||
}
|
||||
oauthConfig = {
|
||||
appId: credential.appId,
|
||||
appSecret: credential.appSecret,
|
||||
redirectUri,
|
||||
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
setSessionCookie(reply, config, {
|
||||
userId: user.id,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
});
|
||||
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
|
||||
let userId: string;
|
||||
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
|
||||
const identity = await upsertScopedFeishuIdentity(config.prisma, {
|
||||
connectionId: statePayload.connectionId,
|
||||
openId: feishuUser.openId,
|
||||
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
|
||||
displayName: feishuUser.displayName,
|
||||
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
|
||||
});
|
||||
if (identity.organizationId !== statePayload.organizationId) {
|
||||
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
|
||||
}
|
||||
userId = identity.userId;
|
||||
setSessionCookie(reply, config, {
|
||||
userId,
|
||||
feishuIdentityId: identity.identityId,
|
||||
feishuConnectionId: identity.connectionId,
|
||||
feishuOrganizationId: identity.organizationId,
|
||||
});
|
||||
} else {
|
||||
const user = await config.prisma.user.upsert({
|
||||
where: { feishuOpenId: feishuUser.openId },
|
||||
create: {
|
||||
feishuOpenId: feishuUser.openId,
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
update: {
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
setSessionCookie(reply, config, {
|
||||
userId,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
});
|
||||
}
|
||||
|
||||
const destination = await resolvePostLoginRedirect(
|
||||
config.prisma,
|
||||
user.id,
|
||||
userId,
|
||||
statePayload.returnTo,
|
||||
statePayload.organizationId !== undefined
|
||||
? { intendedOrganizationId: statePayload.organizationId }
|
||||
: {},
|
||||
);
|
||||
return reply.redirect(destination);
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "feishu oauth callback failed");
|
||||
request.log.error({
|
||||
requestId: request.id,
|
||||
operation: "feishu_oauth.callback",
|
||||
...(err instanceof FeishuOAuthError
|
||||
? {
|
||||
errorCode: err.code,
|
||||
failureCategory: err.category,
|
||||
...(err.upstreamStatus !== undefined ? { upstreamStatus: err.upstreamStatus } : {}),
|
||||
}
|
||||
: {
|
||||
errorCode: "feishu_oauth_callback_failed",
|
||||
errorType: err instanceof Error ? err.name : typeof err,
|
||||
err,
|
||||
}),
|
||||
}, "feishu oauth callback failed");
|
||||
return reply.redirect(`/admin/login?error=${encodeURIComponent("oauth_failed")}`);
|
||||
}
|
||||
});
|
||||
@@ -138,7 +241,13 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
if (auth === null) return;
|
||||
|
||||
const memberships = await config.prisma.organizationMembership.findMany({
|
||||
where: { userId: auth.user.id, revokedAt: null },
|
||||
where: {
|
||||
userId: auth.user.id,
|
||||
revokedAt: null,
|
||||
...(auth.authenticationOrganizationId !== undefined
|
||||
? { organizationId: auth.authenticationOrganizationId }
|
||||
: {}),
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
organization: {
|
||||
@@ -151,7 +260,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
return {
|
||||
user: {
|
||||
id: auth.user.id,
|
||||
feishuOpenId: auth.user.feishuOpenId,
|
||||
feishuOpenId: auth.feishuOpenId,
|
||||
displayName: auth.user.displayName,
|
||||
avatarUrl: auth.user.avatarUrl,
|
||||
},
|
||||
@@ -170,12 +279,16 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
|
||||
// Minimal login page until admin SPA lands (PR7).
|
||||
app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const q = request.query as { error?: string; returnTo?: string };
|
||||
const q = request.query as { error?: string; returnTo?: string; orgSlug?: string };
|
||||
const returnTo = sanitizeReturnTo(q.returnTo ?? "");
|
||||
const loginHref =
|
||||
returnTo === "/admin"
|
||||
? "/auth/feishu"
|
||||
: `/auth/feishu?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
const orgSlug = typeof q.orgSlug === "string" && /^[a-z0-9][a-z0-9-]*$/.test(q.orgSlug)
|
||||
? q.orgSlug
|
||||
: null;
|
||||
const loginHref = orgSlug === null
|
||||
? null
|
||||
: returnTo === "/admin"
|
||||
? `/auth/feishu/${encodeURIComponent(orgSlug)}`
|
||||
: `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
const errorHtml =
|
||||
typeof q.error === "string" && q.error !== ""
|
||||
? `<p class="err">Login failed: ${escapeHtml(q.error)}</p>`
|
||||
@@ -199,7 +312,9 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
<h1>Org Admin</h1>
|
||||
<p>Sign in with Feishu to manage your organization.</p>
|
||||
${errorHtml}
|
||||
<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>
|
||||
${loginHref === null
|
||||
? "<p>请从组织专属登录链接进入。</p>"
|
||||
: `<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>`}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -210,7 +325,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
export function setSessionCookie(
|
||||
reply: FastifyReply,
|
||||
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
|
||||
identity: { readonly userId: string; readonly feishuOpenId: string },
|
||||
identity: SessionIdentity,
|
||||
): void {
|
||||
const token = signSession(identity, config.sessionSecret);
|
||||
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
@@ -224,7 +339,7 @@ export function setSessionCookie(
|
||||
|
||||
/** Exported for tests that need a pre-authenticated cookie value. */
|
||||
export function mintSessionToken(
|
||||
identity: { readonly userId: string; readonly feishuOpenId: string },
|
||||
identity: SessionIdentity,
|
||||
sessionSecret: string,
|
||||
): string {
|
||||
return signSession(identity, sessionSecret);
|
||||
@@ -238,7 +353,22 @@ async function resolvePostLoginRedirect(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
returnTo: string,
|
||||
options: { readonly intendedOrganizationId?: string | undefined } = {},
|
||||
): Promise<string> {
|
||||
if (options.intendedOrganizationId !== undefined) {
|
||||
const intended = await prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId: options.intendedOrganizationId,
|
||||
revokedAt: null,
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: { organization: { select: { slug: true } } },
|
||||
});
|
||||
if (intended === null) return "/admin?error=not_an_active_org_member";
|
||||
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
||||
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
||||
}
|
||||
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
|
||||
return returnTo;
|
||||
}
|
||||
@@ -267,6 +397,17 @@ async function resolvePostLoginRedirect(
|
||||
return "/admin/login?error=no_organization";
|
||||
}
|
||||
|
||||
function setOAuthNonceCookie(reply: FastifyReply, nonce: string, secure: boolean): void {
|
||||
// Signed state carries scope/returnTo; the cookie mirrors nonce for double-submit CSRF.
|
||||
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
maxAge: 600,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow relative same-origin paths under /admin to avoid open redirects.
|
||||
*/
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function registerExplorerRoutes(
|
||||
}
|
||||
const result = await createOrgProject(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
actorFeishuOpenId: auth.user.feishuOpenId,
|
||||
actorFeishuOpenId: auth.feishuOpenId,
|
||||
name: body.name,
|
||||
workspaceRoot: config.projectWorkspaceRoot,
|
||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||
@@ -219,7 +219,7 @@ export async function registerExplorerRoutes(
|
||||
return await archiveOrgProjectChatBinding(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
actorFeishuOpenId: auth.user.feishuOpenId,
|
||||
actorFeishuOpenId: auth.feishuOpenId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
|
||||
Reference in New Issue
Block a user