forked from bai/curriculum-project-hub
c4f052efa2
Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole guards, and the first org admin APIs (summary + project settings).
294 lines
9.5 KiB
TypeScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|