forked from bai/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user