forked from bai/curriculum-project-hub
feat: auto-join scoped Feishu OAuth users
This commit is contained in:
@@ -5,7 +5,7 @@ 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 { upsertScopedFeishuIdentityInTransaction } from "../../feishu/identityNamespace.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
@@ -164,16 +164,54 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
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 } : {}),
|
||||
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;
|
||||
});
|
||||
if (identity.organizationId !== statePayload.organizationId) {
|
||||
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
|
||||
}
|
||||
userId = identity.userId;
|
||||
setSessionCookie(reply, config, {
|
||||
userId,
|
||||
|
||||
@@ -1160,14 +1160,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "identity_missing",
|
||||
message: `请先通过飞书登录建立身份:${loginUrl}\n登录后仍需由组织管理员将你加入组织。`,
|
||||
message: `请先通过飞书登录并加入组织:${loginUrl}\n完成后返回群聊重试。`,
|
||||
};
|
||||
}
|
||||
if (user.organizationMemberships.length === 0) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "membership_missing",
|
||||
message: "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。",
|
||||
message: "你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
|
||||
};
|
||||
}
|
||||
const membership = user.organizationMemberships[0]!;
|
||||
|
||||
Reference in New Issue
Block a user