forked from EduCraft/curriculum-project-hub
11de9e81db
Default returnTo=/admin previously landed on the static complete page meant for chat onboarding; send users to /admin/org/:slug instead.
506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
/**
|
|
* Feishu OAuth + session routes for org admin web login.
|
|
*/
|
|
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 { upsertScopedFeishuIdentityInTransaction } 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 {
|
|
HttpError,
|
|
requireSession,
|
|
type GuardDeps,
|
|
} from "../auth/guards.js";
|
|
import {
|
|
OAUTH_STATE_COOKIE_NAME,
|
|
SESSION_COOKIE_NAME,
|
|
signOAuthState,
|
|
signSession,
|
|
verifyOAuthState,
|
|
type SessionIdentity,
|
|
} from "../auth/session.js";
|
|
import { handleRouteError } from "../errors.js";
|
|
|
|
export interface AuthRouteConfig {
|
|
readonly prisma: PrismaClient;
|
|
readonly sessionSecret: string;
|
|
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 legacyOauthConfig: FeishuOAuthConfig = {
|
|
appId: config.feishuAppId,
|
|
appSecret: config.feishuAppSecret,
|
|
redirectUri,
|
|
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
|
|
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
|
};
|
|
|
|
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,
|
|
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);
|
|
}
|
|
});
|
|
|
|
app.get("/auth/feishu/callback", async (request, reply) => {
|
|
try {
|
|
const query = request.query as {
|
|
code?: string;
|
|
state?: string;
|
|
error?: string;
|
|
};
|
|
if (typeof query.error === "string" && query.error !== "") {
|
|
return reply.redirect(`/admin/login?error=${encodeURIComponent(query.error)}`);
|
|
}
|
|
const code = query.code;
|
|
const state = query.state;
|
|
if (typeof code !== "string" || code === "" || typeof state !== "string" || state === "") {
|
|
throw new HttpError(400, "bad_request", "missing OAuth code or state");
|
|
}
|
|
|
|
const statePayload = verifyOAuthState(state, config.sessionSecret);
|
|
if (statePayload === null) {
|
|
throw new HttpError(400, "bad_request", "invalid or expired OAuth state");
|
|
}
|
|
const cookieNonce = request.cookies[OAUTH_STATE_COOKIE_NAME];
|
|
if (cookieNonce === undefined || cookieNonce !== statePayload.nonce) {
|
|
throw new HttpError(400, "bad_request", "OAuth state mismatch");
|
|
}
|
|
reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" });
|
|
|
|
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 } : {}),
|
|
};
|
|
}
|
|
|
|
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
|
|
let userId: string;
|
|
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
|
|
const connectionId = statePayload.connectionId;
|
|
const organizationId = statePayload.organizationId;
|
|
const identity = await config.prisma.$transaction(async (tx) => {
|
|
const resolved = await upsertScopedFeishuIdentityInTransaction(tx, {
|
|
connectionId,
|
|
expectedOrganizationId: organizationId,
|
|
openId: feishuUser.openId,
|
|
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
|
|
displayName: feishuUser.displayName,
|
|
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
|
|
});
|
|
const activeMembership = await tx.organizationMembership.findFirst({
|
|
where: {
|
|
organizationId,
|
|
userId: resolved.userId,
|
|
revokedAt: null,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (activeMembership === null) {
|
|
const revokedMembership = await tx.organizationMembership.findFirst({
|
|
where: {
|
|
organizationId,
|
|
userId: resolved.userId,
|
|
revokedAt: { not: null },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (revokedMembership === null) {
|
|
await tx.organizationMembership.create({
|
|
data: {
|
|
organizationId,
|
|
userId: resolved.userId,
|
|
role: "MEMBER",
|
|
},
|
|
});
|
|
await tx.auditEntry.create({
|
|
data: {
|
|
organizationId,
|
|
actorUserId: resolved.userId,
|
|
action: "organization_member.oauth_auto_joined",
|
|
metadata: { connectionId: resolved.connectionId, role: "MEMBER" },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
return resolved;
|
|
});
|
|
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,
|
|
userId,
|
|
statePayload.returnTo,
|
|
statePayload.organizationId !== undefined
|
|
? { intendedOrganizationId: statePayload.organizationId }
|
|
: {},
|
|
);
|
|
return reply.redirect(destination);
|
|
} catch (err) {
|
|
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")}`);
|
|
}
|
|
});
|
|
|
|
app.post("/auth/logout", async (_request, reply) => {
|
|
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
|
return reply.status(204).send();
|
|
});
|
|
|
|
app.get("/auth/feishu/complete", async (request, reply) => {
|
|
const query = request.query as { org?: string };
|
|
const organizationName = typeof query.org === "string" && query.org.trim() !== ""
|
|
? query.org.trim()
|
|
: "当前组织";
|
|
return reply.type("text/html").send(`<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>Educraft 登录成功</title>
|
|
<style>
|
|
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
|
|
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:25rem;text-align:center}
|
|
.ok{font-size:3rem;margin:0 0 .5rem}.hint{color:#646a73;line-height:1.6}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<p class="ok">✅</p>
|
|
<h1>登录并加入组织成功</h1>
|
|
<p>你已加入 ${escapeHtml(organizationName)}。</p>
|
|
<p class="hint">现在可以关闭本页面,返回飞书群再次 @机器人继续使用。</p>
|
|
</main>
|
|
</body>
|
|
</html>`);
|
|
});
|
|
|
|
app.get("/api/me", async (request, reply) => {
|
|
try {
|
|
const auth = await requireSession(request, reply, guardDeps);
|
|
if (auth === null) return;
|
|
|
|
const memberships = await config.prisma.organizationMembership.findMany({
|
|
where: {
|
|
userId: auth.user.id,
|
|
revokedAt: null,
|
|
...(auth.authenticationOrganizationId !== undefined
|
|
? { organizationId: auth.authenticationOrganizationId }
|
|
: {}),
|
|
},
|
|
select: {
|
|
role: true,
|
|
organization: {
|
|
select: { id: true, slug: true, name: true, status: true },
|
|
},
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
|
|
return {
|
|
user: {
|
|
id: auth.user.id,
|
|
feishuOpenId: auth.feishuOpenId,
|
|
displayName: auth.user.displayName,
|
|
avatarUrl: auth.user.avatarUrl,
|
|
},
|
|
organizations: memberships.map((m) => ({
|
|
id: m.organization.id,
|
|
slug: m.organization.slug,
|
|
name: m.organization.name,
|
|
status: m.organization.status,
|
|
role: m.role,
|
|
})),
|
|
};
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
// 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; orgSlug?: string };
|
|
const returnTo = sanitizeReturnTo(q.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>`
|
|
: "";
|
|
const html = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>CPH Org Admin — Login</title>
|
|
<style>
|
|
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
|
|
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:22rem;text-align:center}
|
|
a.btn{display:inline-block;margin-top:1rem;padding:.7rem 1.2rem;background:#3370ff;color:#fff;border-radius:8px;text-decoration:none;font-weight:600}
|
|
a.btn:hover{background:#245bdb}
|
|
.err{color:#c45656;font-size:.9rem}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>Org Admin</h1>
|
|
<p>Sign in with Feishu to manage your organization.</p>
|
|
${errorHtml}
|
|
${loginHref === null
|
|
? "<p>请从组织专属登录链接进入。</p>"
|
|
: `<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>`}
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
return reply.type("text/html").send(html);
|
|
});
|
|
}
|
|
|
|
export function setSessionCookie(
|
|
reply: FastifyReply,
|
|
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
|
|
identity: SessionIdentity,
|
|
): void {
|
|
const token = signSession(identity, config.sessionSecret);
|
|
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
|
path: "/",
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: config.cookieSecure,
|
|
maxAge: 7 * 24 * 60 * 60,
|
|
});
|
|
}
|
|
|
|
/** Exported for tests that need a pre-authenticated cookie value. */
|
|
export function mintSessionToken(
|
|
identity: SessionIdentity,
|
|
sessionSecret: string,
|
|
): string {
|
|
return signSession(identity, sessionSecret);
|
|
}
|
|
|
|
export function sessionCookieHeader(token: string): string {
|
|
return `${SESSION_COOKIE_NAME}=${token}`;
|
|
}
|
|
|
|
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, name: true } } },
|
|
});
|
|
if (intended === null) return "/admin?error=not_an_active_org_member";
|
|
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
|
// Default / missing returnTo sanitizes to "/admin". Always land in the org
|
|
// admin SPA (not the legacy static "close this tab" complete page).
|
|
if (returnTo === "/admin") {
|
|
return orgRoot;
|
|
}
|
|
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
|
}
|
|
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
|
|
return returnTo;
|
|
}
|
|
const membership = await prisma.organizationMembership.findFirst({
|
|
where: {
|
|
userId,
|
|
revokedAt: null,
|
|
role: { in: ["OWNER", "ADMIN"] },
|
|
organization: { status: "ACTIVE" },
|
|
},
|
|
select: { organization: { select: { slug: true } } },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
if (membership !== null) {
|
|
return `/admin/org/${membership.organization.slug}`;
|
|
}
|
|
// Member-only or no org: still land on a shell page (SPA will explain).
|
|
const any = await prisma.organizationMembership.findFirst({
|
|
where: { userId, revokedAt: null, organization: { status: "ACTIVE" } },
|
|
select: { organization: { select: { slug: true } } },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
if (any !== null) {
|
|
return `/admin/org/${any.organization.slug}`;
|
|
}
|
|
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.
|
|
*/
|
|
export function sanitizeReturnTo(raw: string): string {
|
|
if (raw === "" || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("://")) {
|
|
return "/admin";
|
|
}
|
|
if (!raw.startsWith("/admin")) {
|
|
return "/admin";
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
function trimTrailingSlash(url: string): string {
|
|
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|