Files
curriculum-project-hub/hub/src/admin/routes/authRoutes.ts
T
hongjr03 c4f052efa2 feat(hub): add Feishu OAuth session for org admin
Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole
guards, and the first org admin APIs (summary + project settings).
2026-07-10 00:55:19 +08:00

294 lines
9.5 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 {
buildAuthorizeUrl,
DEFAULT_OAUTH_SCOPE,
exchangeCodeForUser,
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,
} 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 oauthScope?: string;
readonly cookieSecure: boolean;
readonly fetchImpl?: typeof fetch;
}
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 = {
appId: config.feishuAppId,
appSecret: config.feishuAppSecret,
redirectUri,
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
};
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);
// 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);
} 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: "/" });
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,
},
});
setSessionCookie(reply, config, {
userId: user.id,
feishuOpenId: user.feishuOpenId,
});
const destination = await resolvePostLoginRedirect(
config.prisma,
user.id,
statePayload.returnTo,
);
return reply.redirect(destination);
} catch (err) {
request.log.error({ 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("/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 },
select: {
role: true,
organization: {
select: { id: true, slug: true, name: true, status: true },
},
},
orderBy: { createdAt: "asc" },
});
return {
user: {
id: auth.user.id,
feishuOpenId: auth.user.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 };
const returnTo = sanitizeReturnTo(q.returnTo ?? "");
const loginHref =
returnTo === "/admin"
? "/auth/feishu"
: `/auth/feishu?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}
<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: { readonly userId: string; readonly feishuOpenId: string },
): 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: { readonly userId: string; readonly feishuOpenId: string },
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,
): Promise<string> {
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";
}
/**
* 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}