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
+183 -42
View File
@@ -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.
*/