diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index cff8b6279d..b8f8556571 100644 --- a/ee/apps/den-api/src/auth.ts +++ b/ee/apps/den-api/src/auth.ts @@ -1,5 +1,6 @@ import { getInitialActiveOrganizationIdForUser } from "./active-organization.js"; import { db } from "./db.js"; +import { isEntraSsoEnabled, mapEntraProfileToUser, normalizeEntraTenantId } from "./entra-sso.js"; import { env } from "./env.js"; import { syncDenSignupContact } from "./loops.js"; import { sendEmail } from "./utils/email/send-email.js"; @@ -12,7 +13,7 @@ import { denOrganizationAccess, denOrganizationStaticRoles, } from "./organization-access.js"; -import { seedDefaultOrganizationRoles } from "./orgs.js"; +import { ensureEntraSsoMembershipForAccount, seedDefaultOrganizationRoles } from "./orgs.js"; import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"; import * as schema from "@openwork-ee/den-db/schema"; import { apiKey } from "@better-auth/api-key"; @@ -73,6 +74,18 @@ const socialProviders = { }, } : {}), + ...(isEntraSsoEnabled(env.entra) + ? { + microsoft: { + clientId: env.entra.clientId!, + clientSecret: env.entra.clientSecret!, + tenantId: normalizeEntraTenantId(env.entra.tenantId), + authority: "https://login.microsoftonline.com", + scope: ["openid", "profile", "email"], + mapProfileToUser: mapEntraProfileToUser, + }, + } + : {}), }; function hasRole(roleValue: string, roleName: string) { @@ -111,6 +124,10 @@ function buildInvitationLink(invitationId: string) { ).toString(); } +export function getSignUpEmailRateLimitMax(devMode = env.devMode) { + return devMode ? 100 : 3; +} + function hasMcpScope(scopes: readonly string[]) { return scopes.some((scope) => scope.startsWith("mcp:")); } @@ -129,6 +146,30 @@ export const auth = betterAuth({ schema, }), databaseHooks: { + account: { + create: { + after: async (account) => { + if (account.providerId === "microsoft") { + await ensureEntraSsoMembershipForAccount({ + idToken: account.idToken, + providerId: account.providerId, + userId: normalizeDenTypeId("user", account.userId), + }); + } + }, + }, + update: { + after: async (account) => { + if (account.providerId === "microsoft") { + await ensureEntraSsoMembershipForAccount({ + idToken: account.idToken, + providerId: account.providerId, + userId: normalizeDenTypeId("user", account.userId), + }); + } + }, + }, + }, session: { create: { before: async (session) => { @@ -211,7 +252,7 @@ export const auth = betterAuth({ }, "/sign-up/email": { window: 3600, - max: env.devMode ? 100 : 5, + max: getSignUpEmailRateLimitMax(), }, "/email-otp/send-verification-otp": { window: 3600, diff --git a/ee/apps/den-api/src/entra-sso.ts b/ee/apps/den-api/src/entra-sso.ts new file mode 100644 index 0000000000..9d0921ed03 --- /dev/null +++ b/ee/apps/den-api/src/entra-sso.ts @@ -0,0 +1,340 @@ +export type DenSsoOrganizationRole = "admin" | "member" + +export type EntraSsoConfig = { + clientId?: string + clientSecret?: string + tenantId?: string + autoJoinEnabled: boolean + autoJoinOrganizationId?: string + autoJoinOrganizationSlug?: string + adminGroupIds: string[] + memberGroupIds: string[] +} + +export type EntraSsoEnvIssue = { + path: string + message: string +} + +const ENTRA_TENANT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const MULTI_TENANT_ENTRA_ALIASES = new Set(["common", "organizations", "consumers"]) + +export type EntraProfile = { + email?: string | null + name?: string | null + oid?: string | null + preferred_username?: string | null + sub?: string | null + tid?: string | null + upn?: string | null +} + +export type EntraTokenClaims = { + groups?: unknown +} + +export type EntraSsoMembershipRecord = { + id: string + role: string +} + +export type EnsureEntraSsoMembershipDeps = { + resolveOrganizationId: (input: { organizationId?: string; organizationSlug?: string }) => Promise + getExistingMember: (input: { organizationId: string; userId: string }) => Promise + createMember: (input: { organizationId: string; userId: string; role: DenSsoOrganizationRole }) => Promise + updateMemberRole: (input: { memberId: string; role: DenSsoOrganizationRole }) => Promise + ensureDefaultRoles: (organizationId: string) => Promise + isOwnerRole: (role: string) => boolean +} + +function optionalString(value: string | undefined) { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +function splitCsv(value: string | undefined) { + return (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) +} + +export function parseEntraSsoEnv(input: { + DEN_ENTRA_TENANT_ID?: string + DEN_ENTRA_CLIENT_ID?: string + DEN_ENTRA_CLIENT_SECRET?: string + DEN_ENTRA_AUTO_JOIN_ENABLED?: string + DEN_ENTRA_AUTO_JOIN_ORG_ID?: string + DEN_ENTRA_AUTO_JOIN_ORG_SLUG?: string + DEN_ENTRA_ADMIN_GROUP_IDS?: string + DEN_ENTRA_MEMBER_GROUP_IDS?: string +}): EntraSsoConfig { + return { + tenantId: normalizeEntraTenantId(input.DEN_ENTRA_TENANT_ID), + clientId: optionalString(input.DEN_ENTRA_CLIENT_ID), + clientSecret: optionalString(input.DEN_ENTRA_CLIENT_SECRET), + autoJoinEnabled: (input.DEN_ENTRA_AUTO_JOIN_ENABLED ?? "false").toLowerCase() === "true", + autoJoinOrganizationId: optionalString(input.DEN_ENTRA_AUTO_JOIN_ORG_ID), + autoJoinOrganizationSlug: optionalString(input.DEN_ENTRA_AUTO_JOIN_ORG_SLUG), + adminGroupIds: splitCsv(input.DEN_ENTRA_ADMIN_GROUP_IDS), + memberGroupIds: splitCsv(input.DEN_ENTRA_MEMBER_GROUP_IDS), + } +} + +export function validateEntraSsoEnv(input: { + DEN_ENTRA_TENANT_ID?: string + DEN_ENTRA_CLIENT_ID?: string + DEN_ENTRA_CLIENT_SECRET?: string + DEN_ENTRA_AUTO_JOIN_ENABLED?: string + DEN_ENTRA_AUTO_JOIN_ORG_ID?: string + DEN_ENTRA_AUTO_JOIN_ORG_SLUG?: string + BETTER_AUTH_URL?: string + DEN_BETTER_AUTH_TRUSTED_ORIGINS?: string + CORS_ORIGINS?: string +}) { + const issues: EntraSsoEnvIssue[] = [] + const hasAnyProviderValue = Boolean(input.DEN_ENTRA_TENANT_ID || input.DEN_ENTRA_CLIENT_ID || input.DEN_ENTRA_CLIENT_SECRET) + + if (hasAnyProviderValue) { + for (const key of ["DEN_ENTRA_TENANT_ID", "DEN_ENTRA_CLIENT_ID", "DEN_ENTRA_CLIENT_SECRET"] as const) { + if (!input[key]?.trim()) { + issues.push({ + path: key, + message: `${key} is required when configuring Microsoft Entra SSO`, + }) + } + } + + const tenantId = input.DEN_ENTRA_TENANT_ID?.trim() + if (tenantId && !normalizeEntraTenantId(tenantId)) { + issues.push({ + path: "DEN_ENTRA_TENANT_ID", + message: "DEN_ENTRA_TENANT_ID must be a fixed Entra tenant GUID; common, organizations, and consumers are not allowed", + }) + } + + const trustedOrigins = splitCsv(input.DEN_BETTER_AUTH_TRUSTED_ORIGINS) + const effectiveTrustedOrigins = trustedOrigins.length > 0 ? trustedOrigins : splitCsv(input.CORS_ORIGINS) + const trustedOriginsPath = trustedOrigins.length > 0 ? "DEN_BETTER_AUTH_TRUSTED_ORIGINS" : "CORS_ORIGINS" + for (const origin of effectiveTrustedOrigins) { + if (origin.trim() === "*") { + issues.push({ + path: trustedOriginsPath, + message: "Wildcard trusted origins are not allowed when Microsoft Entra SSO is configured", + }) + continue + } + + if (!isSafeEntraAuthOrigin(origin)) { + issues.push({ + path: trustedOriginsPath, + message: "Microsoft Entra SSO trusted origins must use https, except http is allowed for localhost, loopback, private LAN IPs, or .local hostnames", + }) + } + } + + if (input.BETTER_AUTH_URL && !isSafeEntraAuthOrigin(input.BETTER_AUTH_URL)) { + issues.push({ + path: "BETTER_AUTH_URL", + message: "BETTER_AUTH_URL must use https, except http is allowed for localhost, loopback, private LAN IPs, or .local hostnames", + }) + } + } + + if ((input.DEN_ENTRA_AUTO_JOIN_ENABLED ?? "false").trim().toLowerCase() === "true") { + const hasOrgId = Boolean(input.DEN_ENTRA_AUTO_JOIN_ORG_ID?.trim()) + const hasOrgSlug = Boolean(input.DEN_ENTRA_AUTO_JOIN_ORG_SLUG?.trim()) + if (hasOrgId === hasOrgSlug) { + issues.push({ + path: "DEN_ENTRA_AUTO_JOIN_ORG_ID", + message: "Exactly one of DEN_ENTRA_AUTO_JOIN_ORG_ID or DEN_ENTRA_AUTO_JOIN_ORG_SLUG is required when DEN_ENTRA_AUTO_JOIN_ENABLED=true", + }) + } + } + + return issues +} + +export function isEntraSsoEnabled(config: Pick) { + return Boolean(config.clientId && config.clientSecret && config.tenantId) +} + +export function normalizeEntraTenantId(value: string | undefined) { + const tenantId = value?.trim() + if (!tenantId || MULTI_TENANT_ENTRA_ALIASES.has(tenantId.toLowerCase()) || !ENTRA_TENANT_ID_PATTERN.test(tenantId)) { + return undefined + } + return tenantId.toLowerCase() +} + +function isPrivateLanIpv4(hostname: string) { + const parts = hostname.split(".").map((part) => Number(part)) + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false + } + + const [first, second] = parts + return first === 10 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) +} + +export function isSafeEntraAuthOrigin(origin: string) { + try { + const parsed = new URL(origin) + if (parsed.protocol === "https:") { + return true + } + if (parsed.protocol !== "http:") { + return false + } + + const hostname = parsed.hostname.toLowerCase() + return hostname === "localhost" + || hostname === "127.0.0.1" + || hostname === "::1" + || hostname.endsWith(".local") + || isPrivateLanIpv4(hostname) + } catch { + return false + } +} + +export function mapEntraProfileToUser(profile: EntraProfile) { + const email = profile.email?.trim() + || profile.preferred_username?.trim() + || profile.upn?.trim() + || (profile.oid?.trim() ? `${profile.oid.trim()}@entra.local` : undefined) + || (profile.sub?.trim() ? `${profile.sub.trim()}@entra.local` : undefined) + + return { + email, + emailVerified: Boolean(email), + name: profile.name?.trim() || email || "Microsoft Entra user", + } +} + +function decodeJwtPayload(token: string) { + const payload = token.split(".")[1] + if (!payload) { + return null + } + + try { + const normalized = payload.replace(/-/g, "+").replace(/_/g, "/") + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=") + return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record + } catch { + return null + } +} + +export function extractEntraGroupsFromClaims(claims: EntraTokenClaims | null | undefined) { + if (!Array.isArray(claims?.groups)) { + return [] + } + + return claims.groups + .filter((group): group is string => typeof group === "string") + .map((group) => group.trim()) + .filter(Boolean) +} + +export function extractEntraGroupsFromIdToken(idToken: string | null | undefined) { + if (!idToken) { + return [] + } + + return extractEntraGroupsFromClaims(decodeJwtPayload(idToken)) +} + +export function resolveEntraSsoRole(input: { + groups: readonly string[] + adminGroupIds: readonly string[] + memberGroupIds: readonly string[] +}): DenSsoOrganizationRole { + const groups = new Set(input.groups.map((group) => group.trim()).filter(Boolean)) + + if (input.adminGroupIds.some((groupId) => groups.has(groupId))) { + return "admin" + } + + if (input.memberGroupIds.some((groupId) => groups.has(groupId))) { + return "member" + } + + return "member" +} + +export function normalizeSsoAssignableRole(role: string): DenSsoOrganizationRole { + return role === "admin" ? "admin" : "member" +} + +export async function ensureEntraSsoMembership(input: { + userId: string + providerId?: string | null + idToken?: string | null + config: Pick + deps: EnsureEntraSsoMembershipDeps +}) { + if (input.providerId !== "microsoft") { + return { status: "provider_not_microsoft" as const } + } + + if (!input.config.autoJoinEnabled) { + return { status: "disabled" as const } + } + + const hasOrgId = Boolean(input.config.autoJoinOrganizationId?.trim()) + const hasOrgSlug = Boolean(input.config.autoJoinOrganizationSlug?.trim()) + if (hasOrgId === hasOrgSlug) { + return { status: "invalid_organization_selector" as const } + } + + const organizationId = await input.deps.resolveOrganizationId({ + organizationId: input.config.autoJoinOrganizationId, + organizationSlug: input.config.autoJoinOrganizationSlug, + }) + if (!organizationId) { + return { status: "organization_not_found" as const } + } + + const groups = extractEntraGroupsFromIdToken(input.idToken) + const role = normalizeSsoAssignableRole(resolveEntraSsoRole({ + groups, + adminGroupIds: input.config.adminGroupIds, + memberGroupIds: input.config.memberGroupIds, + })) + + const existingMember = await input.deps.getExistingMember({ + organizationId, + userId: input.userId, + }) + + if (!existingMember) { + const member = await input.deps.createMember({ + organizationId, + userId: input.userId, + role, + }) + await input.deps.ensureDefaultRoles(organizationId) + return { status: "created" as const, member, role } + } + + if (input.deps.isOwnerRole(existingMember.role)) { + await input.deps.ensureDefaultRoles(organizationId) + return { status: "owner_preserved" as const, member: existingMember, role: existingMember.role } + } + + if (existingMember.role !== role) { + const member = await input.deps.updateMemberRole({ + memberId: existingMember.id, + role, + }) + await input.deps.ensureDefaultRoles(organizationId) + return { status: "updated" as const, member, role } + } + + await input.deps.ensureDefaultRoles(organizationId) + return { status: "unchanged" as const, member: existingMember, role } +} diff --git a/ee/apps/den-api/src/env.ts b/ee/apps/den-api/src/env.ts index 61cbd19614..0af8e77d8c 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -1,4 +1,5 @@ import { DEN_WORKER_POLL_INTERVAL_MS } from "./CONSTS.js" +import { parseEntraSsoEnv, validateEntraSsoEnv } from "./entra-sso.js" import { z } from "zod" const EnvSchema = z.object({ @@ -21,6 +22,14 @@ const EnvSchema = z.object({ GITHUB_CONNECTOR_APP_WEBHOOK_SECRET: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), + DEN_ENTRA_TENANT_ID: z.string().optional(), + DEN_ENTRA_CLIENT_ID: z.string().optional(), + DEN_ENTRA_CLIENT_SECRET: z.string().optional(), + DEN_ENTRA_AUTO_JOIN_ENABLED: z.string().optional(), + DEN_ENTRA_AUTO_JOIN_ORG_ID: z.string().optional(), + DEN_ENTRA_AUTO_JOIN_ORG_SLUG: z.string().optional(), + DEN_ENTRA_ADMIN_GROUP_IDS: z.string().optional(), + DEN_ENTRA_MEMBER_GROUP_IDS: z.string().optional(), EMAIL_FROM: z.string().optional(), DEN_REQUIRE_EMAIL_VERIFICATION: z.string().optional(), RESEND_API_KEY: z.string().optional(), @@ -123,6 +132,14 @@ const EnvSchema = z.object({ } } } + + for (const issue of validateEntraSsoEnv(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: [issue.path], + }) + } }) const parsed = EnvSchema.parse(process.env) @@ -159,7 +176,6 @@ const requireEmailVerification = parsed.DEN_REQUIRE_EMAIL_VERIFICATION === undef ? !devMode : parsed.DEN_REQUIRE_EMAIL_VERIFICATION.trim().toLowerCase() !== "false" const port = Number(parsed.PORT ?? "8790") - const daytonaSandboxPublic = (parsed.DAYTONA_SANDBOX_PUBLIC ?? "false").toLowerCase() === "true" @@ -202,6 +218,7 @@ export const env = { clientId: optionalString(parsed.GOOGLE_CLIENT_ID), clientSecret: optionalString(parsed.GOOGLE_CLIENT_SECRET), }, + entra: parseEntraSsoEnv(parsed), email: { from: optionalString(parsed.EMAIL_FROM), }, diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 1100d31b64..dc3f19c5bf 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -1,6 +1,7 @@ -import { and, asc, count, eq, inArray, isNull } from "@openwork-ee/den-db/drizzle" +import { and, asc, count, desc, eq, inArray, isNull, or } from "@openwork-ee/den-db/drizzle" import { AuthSessionTable, + AuthAccountTable, AuthUserTable, InvitationTable, MemberTable, @@ -11,6 +12,8 @@ import { } from "@openwork-ee/den-db/schema" import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { db } from "./db.js" +import { env } from "./env.js" +import { ensureEntraSsoMembership } from "./entra-sso.js" import { runPostOrganizationMemberChangeHooks } from "./organization-member-hooks.js" import { DEFAULT_ORGANIZATION_LIMITS, normalizeOrganizationMetadata, serializeOrganizationMetadata } from "./organization-limits.js" import { denDefaultDynamicOrganizationRoles, denOrganizationStaticRoles } from "./organization-access.js" @@ -73,19 +76,16 @@ export type OrganizationContext = { userId: UserId role: string createdAt: Date - joinedAt: Date | null isOwner: boolean } members: Array<{ id: MemberId - userId: UserId | null - inviteId: InvitationRow["id"] | null + userId: UserId role: string createdAt: Date - joinedAt: Date | null isOwner: boolean user: { - id: UserId | MemberId + id: UserId email: string name: string image: string | null @@ -98,7 +98,6 @@ export type OrganizationContext = { status: string expiresAt: Date createdAt: Date - inviteToken: string | null }> roles: Array<{ id: string @@ -215,20 +214,6 @@ function getEmailDomain(email: string) { return normalized.slice(atIndex + 1) } -function getEmailLocalPart(email: string) { - const atIndex = email.indexOf("@") - return atIndex > 0 ? email.slice(0, atIndex) : email -} - -function getEmailDomainName(email: string) { - const domain = getEmailDomain(email) - return domain?.split(".")[0] ?? "invited" -} - -function getInvitedMemberName(email: string) { - return `${getEmailLocalPart(email)} ${getEmailDomainName(email)}`.trim() -} - export function isEmailAllowedForOrganization(allowedEmailDomains: readonly string[] | null | undefined, email: string) { if (!allowedEmailDomains || allowedEmailDomains.length === 0) { return true @@ -310,31 +295,115 @@ function getInvitationStatus(invitation: Pick new Date() ? "pending" : "expired" } +export function parseInvitationLookupIdentifier(invitationIdOrTokenRaw: string) { + const invitationIdOrToken = invitationIdOrTokenRaw.trim() + let invitationId: InvitationRow["id"] | null = null + try { + invitationId = normalizeDenTypeId("invitation", invitationIdOrToken) + } catch {} + + return { invitationId, inviteToken: invitationIdOrToken } +} + +function getInvitationLookupWhere(invitationIdOrTokenRaw: string) { + const { invitationId, inviteToken } = parseInvitationLookupIdentifier(invitationIdOrTokenRaw) + + return invitationId + ? or(eq(InvitationTable.id, invitationId), eq(InvitationTable.inviteToken, inviteToken)) + : eq(InvitationTable.inviteToken, inviteToken) +} + async function getInvitationById(invitationIdRaw: string) { - const tokenRows = await db + const rows = await db .select() .from(InvitationTable) - .where(eq(InvitationTable.inviteToken, invitationIdRaw)) + .where(getInvitationLookupWhere(invitationIdRaw)) .limit(1) - if (tokenRows[0]) { - return tokenRows[0] - } + return rows[0] ?? null +} - let invitationId - try { - invitationId = normalizeDenTypeId("invitation", invitationIdRaw) - } catch { +async function findActiveMemberForUser(input: { + organizationId: OrgId + userId: UserId +}): Promise { + const rows = await db + .select() + .from(MemberTable) + .where(and(eq(MemberTable.organizationId, input.organizationId), eq(MemberTable.userId, input.userId), isNull(MemberTable.removedAt))) + .limit(1) + + return rows[0] ?? null +} + +async function claimInvitationPlaceholderMember(input: { + invitation: InvitationRow + userId: UserId + role: string +}): Promise { + const placeholderRows = await db + .select({ id: MemberTable.id }) + .from(MemberTable) + .where(and( + eq(MemberTable.organizationId, input.invitation.organizationId), + eq(MemberTable.inviteId, input.invitation.id), + isNull(MemberTable.userId), + isNull(MemberTable.removedAt), + )) + .limit(1) + + const placeholder = placeholderRows[0] + if (!placeholder) { return null } - const rows = await db + await db + .update(MemberTable) + .set({ userId: input.userId, role: input.role, joinedAt: new Date() }) + .where(eq(MemberTable.id, placeholder.id)) + + const claimedRows = await db .select() - .from(InvitationTable) - .where(eq(InvitationTable.id, invitationId)) + .from(MemberTable) + .where(eq(MemberTable.id, placeholder.id)) .limit(1) - return rows[0] ?? null + return claimedRows[0] ?? null +} + +async function ensureInvitationTeamMembership(input: { + invitation: InvitationRow + memberId: MemberId +}) { + if (!input.invitation.teamId) { + return + } + + const teams = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(eq(TeamTable.id, input.invitation.teamId)) + .limit(1) + + if (!teams[0]) { + return + } + + const existingTeamMember = await db + .select({ id: TeamMemberTable.id }) + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.teamId, input.invitation.teamId), eq(TeamMemberTable.orgMembershipId, input.memberId))) + .limit(1) + + if (existingTeamMember[0]) { + return + } + + await db.insert(TeamMemberTable).values({ + id: createDenTypeId("teamMember"), + teamId: input.invitation.teamId, + orgMembershipId: input.memberId, + }) } async function ensureDefaultDynamicRoles(orgId: OrgId) { @@ -395,7 +464,6 @@ async function insertMemberIfMissing(input: { organizationId: input.organizationId, userId: input.userId, role: input.role, - joinedAt: new Date(), }) const created = await db @@ -411,33 +479,119 @@ async function insertMemberIfMissing(input: { return created[0] } -async function acceptInvitation(invitation: InvitationRow, userId: UserId) { - const availableRoles = await listAssignableRoles(invitation.organizationId) - const role = normalizeAssignableRole(invitation.role, availableRoles) - const joinedAt = new Date() +async function resolveEntraAutoJoinOrganizationId(input: { + organizationId?: string + organizationSlug?: string +}): Promise { + if (input.organizationId) { + try { + const organizationId = normalizeDenTypeId("organization", input.organizationId) + const rows = await db + .select({ id: OrganizationTable.id }) + .from(OrganizationTable) + .where(eq(OrganizationTable.id, organizationId)) + .limit(1) - const existingMemberRows = await db - .select() - .from(MemberTable) - .where(and(eq(MemberTable.organizationId, invitation.organizationId), eq(MemberTable.userId, userId), isNull(MemberTable.removedAt))) - .limit(1) + return rows[0]?.id ?? null + } catch { + return null + } + } - const invitedMemberRows = await db - .select() - .from(MemberTable) - .where(and(eq(MemberTable.inviteId, invitation.id), eq(MemberTable.organizationId, invitation.organizationId), isNull(MemberTable.removedAt))) + const slug = input.organizationSlug?.trim() + if (!slug) { + return null + } + + const rows = await db + .select({ id: OrganizationTable.id }) + .from(OrganizationTable) + .where(eq(OrganizationTable.slug, slug)) + .limit(2) + + return rows.length === 1 ? rows[0].id : null +} + +async function latestMicrosoftIdTokenForUser(userId: UserId) { + const rows = await db + .select({ idToken: AuthAccountTable.idToken }) + .from(AuthAccountTable) + .where(and(eq(AuthAccountTable.userId, userId), eq(AuthAccountTable.providerId, "microsoft"))) + .orderBy(desc(AuthAccountTable.updatedAt)) .limit(1) - const invitedMember = invitedMemberRows[0] ?? null - const existingMember = existingMemberRows[0] ?? null - let member = existingMember + return rows[0]?.idToken ?? null +} - if (!member && invitedMember) { - await db - .update(MemberTable) - .set({ userId, role, joinedAt }) - .where(eq(MemberTable.id, invitedMember.id)) - member = { ...invitedMember, userId, role, joinedAt } +export async function ensureEntraSsoMembershipForAccount(input: { + userId: UserId + providerId?: string | null + idToken?: string | null +}) { + const idToken = input.idToken ?? await latestMicrosoftIdTokenForUser(input.userId) + const result = await ensureEntraSsoMembership({ + userId: input.userId, + providerId: input.providerId, + idToken, + config: env.entra, + deps: { + resolveOrganizationId: async (selector) => resolveEntraAutoJoinOrganizationId(selector) as Promise, + getExistingMember: async ({ organizationId, userId }) => { + const existing = await db + .select() + .from(MemberTable) + .where(and(eq(MemberTable.organizationId, organizationId as OrgId), eq(MemberTable.userId, userId as UserId), isNull(MemberTable.removedAt))) + .limit(1) + + return existing[0] ?? null + }, + createMember: async ({ organizationId, userId, role }) => insertMemberIfMissing({ + organizationId: organizationId as OrgId, + userId: userId as UserId, + role, + }), + updateMemberRole: async ({ memberId, role }) => { + await db + .update(MemberTable) + .set({ role }) + .where(eq(MemberTable.id, memberId as MemberId)) + + const updatedRows = await db + .select() + .from(MemberTable) + .where(eq(MemberTable.id, memberId as MemberId)) + .limit(1) + if (!updatedRows[0]) { + throw new Error("failed_to_update_member") + } + return updatedRows[0] + }, + ensureDefaultRoles: async (organizationId) => ensureDefaultDynamicRoles(organizationId as OrgId), + isOwnerRole: roleIncludesOwner, + }, + }) + + if (result.status === "created") { + await runPostOrganizationMemberChangeHooks({ + organizationId: result.member.organizationId, + memberId: result.member.id, + change: "added", + }) + } + + return result +} + +async function acceptInvitation(invitation: InvitationRow, userId: UserId) { + const availableRoles = await listAssignableRoles(invitation.organizationId) + const role = normalizeAssignableRole(invitation.role, availableRoles) + + let createdMember = false + let member = await findActiveMemberForUser({ organizationId: invitation.organizationId, userId }) + + if (!member) { + member = await claimInvitationPlaceholderMember({ invitation, userId, role }) + createdMember = Boolean(member) } if (!member) { @@ -446,38 +600,17 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) { userId, role, }) + createdMember = true } - if (invitation.teamId) { - const teams = await db - .select({ id: TeamTable.id }) - .from(TeamTable) - .where(eq(TeamTable.id, invitation.teamId)) - .limit(1) - - if (teams[0]) { - const existingTeamMember = await db - .select({ id: TeamMemberTable.id }) - .from(TeamMemberTable) - .where(and(eq(TeamMemberTable.teamId, invitation.teamId), eq(TeamMemberTable.orgMembershipId, member.id))) - .limit(1) - - if (!existingTeamMember[0]) { - await db.insert(TeamMemberTable).values({ - id: createDenTypeId("teamMember"), - teamId: invitation.teamId, - orgMembershipId: member.id, - }) - } - } - } + await ensureInvitationTeamMembership({ invitation, memberId: member.id }) await db .update(InvitationTable) .set({ status: "accepted" }) .where(eq(InvitationTable.id, invitation.id)) - return member + return { member, createdMember } } export async function acceptInvitationForUser(input: { @@ -514,20 +647,17 @@ export async function acceptInvitationForUser(input: { throw new OrganizationEmailDomainRestrictionError(input.email, allowedEmailDomains ?? []) } - const member = await acceptInvitation(invitation, input.userId) - await runPostOrganizationMemberChangeHooks({ organizationId: invitation.organizationId, memberId: member.id, change: "added" }) + const accepted = await acceptInvitation(invitation, input.userId) + if (accepted.createdMember) { + await runPostOrganizationMemberChangeHooks({ organizationId: invitation.organizationId, memberId: accepted.member.id, change: "added" }) + } return { invitation, - member, + member: accepted.member, } } export async function getInvitationPreview(invitationIdRaw: string): Promise { - const invitation = await getInvitationById(invitationIdRaw) - if (!invitation) { - return null - } - const rows = await db .select({ invitation: { @@ -547,7 +677,7 @@ export async function getInvitationPreview(invitationIdRaw: string): Promise { - const email = member.user?.email ?? member.invitation?.email ?? "invited@example.com" - const name = member.user?.name ?? getInvitedMemberName(email) - return { - id: member.id, - userId: member.userId, - inviteId: member.inviteId, - role: member.role, - createdAt: member.createdAt, - joinedAt: member.joinedAt, - isOwner: roleIncludesOwner(member.role), - user: { - id: member.user?.id ?? member.id, - email, - name, - image: member.user?.image ?? null, - }, - } - }), + members: members.map((member) => ({ + id: member.id, + userId: member.user.id, + role: member.role, + createdAt: member.createdAt, + user: member.user, + isOwner: roleIncludesOwner(member.role), + })), invitations, roles: [ { @@ -1055,7 +1162,7 @@ export async function removeOrganizationMember(input: { await tx .update(MemberTable) .set({ removedAt: new Date(), removedByOrgMember: input.removedByOrgMemberId ?? null, userId: null }) - .where(eq(MemberTable.id, member.id)) + .where(and(eq(MemberTable.id, member.id), isNull(MemberTable.removedAt))) }) await runPostOrganizationMemberChangeHooks({ organizationId: input.organizationId, memberId: member.id, change: "removed" }) diff --git a/ee/apps/den-api/src/routes/org/invitations.ts b/ee/apps/den-api/src/routes/org/invitations.ts index 2c2e139fd3..cd96a3bac5 100644 --- a/ee/apps/den-api/src/routes/org/invitations.ts +++ b/ee/apps/den-api/src/routes/org/invitations.ts @@ -7,7 +7,6 @@ import { z } from "zod" import { db } from "../../db.js" import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js" -import { runPostOrganizationMemberChangeHooks } from "../../organization-member-hooks.js" import { isEmailAllowedForOrganization, listAssignableRoles, removeOrganizationMember } from "../../orgs.js" import { getOrganizationSeatAddEligibility } from "../../stripe-billing.js" import { DenEmailSendError, sendEmail } from "../../utils/email/send-email.js" @@ -150,8 +149,6 @@ export function registerOrgInvitationRoutes) { + const encodedPayload = Buffer.from(JSON.stringify(payload), "utf8") + .toString("base64url") + return `eyJhbGciOiJub25lIn0.${encodedPayload}.` +} + +test("parses Entra SSO environment into provider and auto-join config", () => { + const config = parseEntraSsoEnv({ + DEN_ENTRA_TENANT_ID: " 00000000-0000-0000-0000-000000000123 ", + DEN_ENTRA_CLIENT_ID: " client-123 ", + DEN_ENTRA_CLIENT_SECRET: " secret-123 ", + DEN_ENTRA_AUTO_JOIN_ENABLED: "true", + DEN_ENTRA_AUTO_JOIN_ORG_ID: " organization_123 ", + DEN_ENTRA_ADMIN_GROUP_IDS: "admin-a, admin-b", + DEN_ENTRA_MEMBER_GROUP_IDS: "member-a,member-b", + }) + + expect(config).toEqual({ + tenantId: "00000000-0000-0000-0000-000000000123", + clientId: "client-123", + clientSecret: "secret-123", + autoJoinEnabled: true, + autoJoinOrganizationId: "organization_123", + autoJoinOrganizationSlug: undefined, + adminGroupIds: ["admin-a", "admin-b"], + memberGroupIds: ["member-a", "member-b"], + }) + expect(isEntraSsoEnabled(config)).toBe(true) +}) + +test("does not enable provider for multi-tenant aliases, non-GUID, or partial Entra config", () => { + expect(normalizeEntraTenantId("common")).toBeUndefined() + expect(normalizeEntraTenantId("organizations")).toBeUndefined() + expect(normalizeEntraTenantId("consumers")).toBeUndefined() + expect(normalizeEntraTenantId("tenant-123")).toBeUndefined() + expect(isEntraSsoEnabled({ + tenantId: normalizeEntraTenantId("common"), + clientId: "client-123", + clientSecret: "secret-123", + })).toBe(false) + expect(isEntraSsoEnabled({ + tenantId: "00000000-0000-0000-0000-000000000123", + clientId: "client-123", + })).toBe(false) +}) + +test("validates fixed tenant, safe origin, and exact auto-join organization selector", () => { + expect(validateEntraSsoEnv({ + DEN_ENTRA_TENANT_ID: "organizations", + DEN_ENTRA_CLIENT_ID: "client-123", + DEN_ENTRA_CLIENT_SECRET: "secret-123", + BETTER_AUTH_URL: "http://public.example.com", + DEN_BETTER_AUTH_TRUSTED_ORIGINS: "*", + DEN_ENTRA_AUTO_JOIN_ENABLED: "true", + DEN_ENTRA_AUTO_JOIN_ORG_ID: "organization_123", + DEN_ENTRA_AUTO_JOIN_ORG_SLUG: "platform", + }).map((issue) => issue.path)).toEqual([ + "DEN_ENTRA_TENANT_ID", + "DEN_BETTER_AUTH_TRUSTED_ORIGINS", + "BETTER_AUTH_URL", + "DEN_ENTRA_AUTO_JOIN_ORG_ID", + ]) + + expect(validateEntraSsoEnv({ + DEN_ENTRA_TENANT_ID: "00000000-0000-0000-0000-000000000123", + DEN_ENTRA_CLIENT_ID: "client-123", + DEN_ENTRA_CLIENT_SECRET: "secret-123", + BETTER_AUTH_URL: "http://192.168.1.50:3005", + DEN_BETTER_AUTH_TRUSTED_ORIGINS: "http://192.168.1.50:3005", + DEN_ENTRA_AUTO_JOIN_ENABLED: "true", + DEN_ENTRA_AUTO_JOIN_ORG_SLUG: "platform", + })).toEqual([]) +}) + +test("rejects public HTTP trusted origins when Entra is enabled", () => { + expect(validateEntraSsoEnv({ + DEN_ENTRA_TENANT_ID: "00000000-0000-0000-0000-000000000123", + DEN_ENTRA_CLIENT_ID: "client-123", + DEN_ENTRA_CLIENT_SECRET: "secret-123", + BETTER_AUTH_URL: "https://den.example.com", + DEN_BETTER_AUTH_TRUSTED_ORIGINS: "http://public.example.com", + }).map((issue) => issue.path)).toEqual(["DEN_BETTER_AUTH_TRUSTED_ORIGINS"]) + + expect(validateEntraSsoEnv({ + DEN_ENTRA_TENANT_ID: "00000000-0000-0000-0000-000000000123", + DEN_ENTRA_CLIENT_ID: "client-123", + DEN_ENTRA_CLIENT_SECRET: "secret-123", + BETTER_AUTH_URL: "https://den.example.com", + CORS_ORIGINS: "https://den.example.com,http://public.example.com", + }).map((issue) => issue.path)).toEqual(["CORS_ORIGINS"]) +}) + +test("allows local and LAN HTTP trusted origins when Entra is enabled", () => { + expect(validateEntraSsoEnv({ + DEN_ENTRA_TENANT_ID: "00000000-0000-0000-0000-000000000123", + DEN_ENTRA_CLIENT_ID: "client-123", + DEN_ENTRA_CLIENT_SECRET: "secret-123", + BETTER_AUTH_URL: "http://localhost:3005", + DEN_BETTER_AUTH_TRUSTED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://192.168.1.50:3005,http://den.company.local:3005", + })).toEqual([]) +}) + +test("maps Entra role from token groups with admin precedence and no owner assignment", () => { + expect(resolveEntraSsoRole({ + groups: ["group-admin", "group-member"], + adminGroupIds: ["group-admin"], + memberGroupIds: ["group-member"], + })).toBe("admin") + + expect(resolveEntraSsoRole({ + groups: ["group-member"], + adminGroupIds: ["group-admin"], + memberGroupIds: ["group-member"], + })).toBe("member") + + expect(resolveEntraSsoRole({ + groups: ["unmapped"], + adminGroupIds: ["group-admin"], + memberGroupIds: ["group-member"], + })).toBe("member") + + expect(resolveEntraSsoRole({ + groups: [], + adminGroupIds: ["group-admin"], + memberGroupIds: ["group-member"], + })).toBe("member") + + expect(normalizeSsoAssignableRole("owner")).toBe("member") +}) + +test("extracts only token groups claim for Entra role mapping", () => { + const token = unsignedJwt({ + groups: [" group-a ", "group-b", 42, ""], + roles: ["owner"], + }) + + expect(extractEntraGroupsFromIdToken(token)).toEqual(["group-a", "group-b"]) + expect(extractEntraGroupsFromIdToken(unsignedJwt({ roles: ["admin"] }))).toEqual([]) +}) + +test("maps Entra profile email fallback to preferred username or UPN", () => { + expect(mapEntraProfileToUser({ + name: "Ada Lovelace", + preferred_username: "ada@example.com", + })).toEqual({ + email: "ada@example.com", + emailVerified: true, + name: "Ada Lovelace", + }) + + expect(mapEntraProfileToUser({ + oid: "00000000-0000-0000-0000-000000000001", + }).email).toBe("00000000-0000-0000-0000-000000000001@entra.local") +}) + +function createMembershipSeam(existingMember?: EntraSsoMembershipRecord | null) { + const calls = { + create: 0, + update: 0, + ensureRoles: 0, + resolveOrganization: 0, + } + let member = existingMember ?? null + + return { + calls, + get member() { + return member + }, + deps: { + resolveOrganizationId: async () => { + calls.resolveOrganization += 1 + return "organization_entra" + }, + getExistingMember: async () => member, + createMember: async (input: { role: "admin" | "member" }) => { + calls.create += 1 + member = { id: "member_created", role: input.role } + return member + }, + updateMemberRole: async (input: { role: "admin" | "member" }) => { + calls.update += 1 + member = { id: member?.id ?? "member_updated", role: input.role } + return member + }, + ensureDefaultRoles: async () => { + calls.ensureRoles += 1 + }, + isOwnerRole: (role: string) => role.split(",").includes("owner"), + }, + } +} + +const autoJoinConfig = { + autoJoinEnabled: true, + autoJoinOrganizationId: "organization_entra", + autoJoinOrganizationSlug: undefined, + adminGroupIds: ["group-admin"], + memberGroupIds: ["group-member"], +} + +test("Microsoft account auto-join creates membership with mapped role", async () => { + const seam = createMembershipSeam() + const result = await ensureEntraSsoMembership({ + userId: "user_entra", + providerId: "microsoft", + idToken: unsignedJwt({ groups: ["group-admin"] }), + config: autoJoinConfig, + deps: seam.deps, + }) + + expect(result.status).toBe("created") + expect(result.role).toBe("admin") + expect(seam.member).toEqual({ id: "member_created", role: "admin" }) + expect(seam.calls).toEqual({ + create: 1, + update: 0, + ensureRoles: 1, + resolveOrganization: 1, + }) +}) + +test("Microsoft account auto-join rejects ambiguous organization selector", async () => { + const seam = createMembershipSeam() + const result = await ensureEntraSsoMembership({ + userId: "user_entra", + providerId: "microsoft", + idToken: unsignedJwt({ groups: ["group-admin"] }), + config: { + ...autoJoinConfig, + autoJoinOrganizationSlug: "platform", + }, + deps: seam.deps, + }) + + expect(result.status).toBe("invalid_organization_selector") + expect(seam.calls).toEqual({ + create: 0, + update: 0, + ensureRoles: 0, + resolveOrganization: 0, + }) +}) + +test("Microsoft account auto-join updates existing non-owner membership", async () => { + const seam = createMembershipSeam({ id: "member_existing", role: "member" }) + const result = await ensureEntraSsoMembership({ + userId: "user_entra", + providerId: "microsoft", + idToken: unsignedJwt({ groups: ["group-admin"] }), + config: autoJoinConfig, + deps: seam.deps, + }) + + expect(result.status).toBe("updated") + expect(result.role).toBe("admin") + expect(seam.member).toEqual({ id: "member_existing", role: "admin" }) + expect(seam.calls.create).toBe(0) + expect(seam.calls.update).toBe(1) +}) + +test("non-Microsoft provider and email/password paths do not auto-join", async () => { + const githubSeam = createMembershipSeam() + const githubResult = await ensureEntraSsoMembership({ + userId: "user_github", + providerId: "github", + idToken: unsignedJwt({ groups: ["group-admin"] }), + config: autoJoinConfig, + deps: githubSeam.deps, + }) + + const emailPasswordSeam = createMembershipSeam() + const emailPasswordResult = await ensureEntraSsoMembership({ + userId: "user_password", + providerId: null, + idToken: null, + config: autoJoinConfig, + deps: emailPasswordSeam.deps, + }) + + expect(githubResult.status).toBe("provider_not_microsoft") + expect(emailPasswordResult.status).toBe("provider_not_microsoft") + expect(githubSeam.calls).toEqual({ create: 0, update: 0, ensureRoles: 0, resolveOrganization: 0 }) + expect(emailPasswordSeam.calls).toEqual({ create: 0, update: 0, ensureRoles: 0, resolveOrganization: 0 }) +}) + +test("Microsoft account auto-join preserves existing owner membership", async () => { + const seam = createMembershipSeam({ id: "member_owner", role: "owner" }) + const result = await ensureEntraSsoMembership({ + userId: "user_owner", + providerId: "microsoft", + idToken: unsignedJwt({ groups: ["group-member"] }), + config: autoJoinConfig, + deps: seam.deps, + }) + + expect(result.status).toBe("owner_preserved") + expect(result.role).toBe("owner") + expect(seam.member).toEqual({ id: "member_owner", role: "owner" }) + expect(seam.calls.create).toBe(0) + expect(seam.calls.update).toBe(0) + expect(seam.calls.ensureRoles).toBe(1) +}) + +test("Microsoft account auto-join preserves existing owner even when admin group matches", async () => { + const seam = createMembershipSeam({ id: "member_owner", role: "owner,admin" }) + const result = await ensureEntraSsoMembership({ + userId: "user_owner", + providerId: "microsoft", + idToken: unsignedJwt({ groups: ["group-admin"] }), + config: autoJoinConfig, + deps: seam.deps, + }) + + expect(result.status).toBe("owner_preserved") + expect(result.role).toBe("owner,admin") + expect(seam.member).toEqual({ id: "member_owner", role: "owner,admin" }) + expect(seam.calls.create).toBe(0) + expect(seam.calls.update).toBe(0) +}) diff --git a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts new file mode 100644 index 0000000000..a9cbfc154d --- /dev/null +++ b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts @@ -0,0 +1,249 @@ +import { beforeAll, beforeEach, expect, mock, test } from "bun:test" +import { createDenTypeId } from "@openwork-ee/utils/typeid" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" +} + +let queryRows: unknown[][] = [] +let operations: Array<{ type: "insert" | "update"; value: any }> = [] +let hookCalls: Array<{ organizationId: string; memberId: string; change: "added" | "removed" }> = [] +let whereInputs: unknown[] = [] + +function queryFor(rows: unknown[]) { + const chain: any = { + from: () => chain, + innerJoin: () => chain, + where: (input: unknown) => { + whereInputs.push(input) + return chain + }, + orderBy: () => rows, + limit: () => rows, + then: (resolve: (value: unknown[]) => unknown) => resolve(rows), + } + return chain +} + +function conditionReferencesRemovedAt(input: unknown, seen = new Set()): boolean { + if (input === null || input === undefined) return false + if (typeof input === "string") return input.includes("removedAt") || input.includes("removed_at") + if (typeof input !== "object" && typeof input !== "function") return false + if (seen.has(input)) return false + seen.add(input) + + const record = input as Record + for (const key of Reflect.ownKeys(input)) { + const keyText = String(key) + if (keyText.includes("removedAt") || keyText.includes("removed_at")) return true + try { + if (conditionReferencesRemovedAt(record[key], seen)) return true + } catch {} + } + + return false +} + +function writeResult() { + return { + onDuplicateKeyUpdate: () => Promise.resolve(), + then: (resolve: (value: undefined) => unknown) => Promise.resolve().then(() => resolve(undefined)), + } +} + +mock.module("../src/db.js", () => ({ + db: { + select: () => queryFor(queryRows.shift() ?? []), + insert: () => ({ + values: (value: any) => { + operations.push({ type: "insert", value }) + return writeResult() + }, + }), + update: () => ({ + set: (value: any) => ({ + where: () => { + operations.push({ type: "update", value }) + return Promise.resolve() + }, + }), + }), + transaction: async (callback: (tx: unknown) => Promise) => callback({ + delete: () => ({ + where: () => Promise.resolve(), + }), + update: () => ({ + set: (value: any) => ({ + where: () => { + operations.push({ type: "update", value }) + return Promise.resolve() + }, + }), + }), + }), + }, +})) + +mock.module("../src/organization-member-hooks.js", () => ({ + runPostOrganizationMemberChangeHooks: (input: { organizationId: string; memberId: string; change: "added" | "removed" }) => { + hookCalls.push(input) + return Promise.resolve() + }, +})) + +let orgsModule: typeof import("../src/orgs.js") + +beforeAll(async () => { + seedRequiredEnv() + orgsModule = await import("../src/orgs.js") +}) + +beforeEach(() => { + queryRows = [] + operations = [] + hookCalls = [] + whereInputs = [] +}) + +test("invitation preview resolves an invite token", async () => { + const invitationId = createDenTypeId("invitation") + const organizationId = createDenTypeId("organization") + const expiresAt = new Date(Date.now() + 60_000) + const createdAt = new Date("2026-06-09T00:00:00.000Z") + queryRows = [[{ + invitation: { + id: invitationId, + email: "teammate@example.com", + role: "member", + status: "pending", + expiresAt, + createdAt, + }, + organization: { + id: organizationId, + name: "Demo Org", + slug: "demo-org", + allowedEmailDomains: null, + }, + }]] + + const preview = await orgsModule.getInvitationPreview("invite-token-123") + + expect(preview?.invitation.id).toBe(invitationId) + expect(preview?.invitation.status).toBe("pending") + expect(preview?.organization.id).toBe(organizationId) +}) + +test("accept by invite token claims placeholder member and emits the acceptance lifecycle hook", async () => { + const invitationId = createDenTypeId("invitation") + const organizationId = createDenTypeId("organization") + const userId = createDenTypeId("user") + const placeholderMemberId = createDenTypeId("member") + const teamId = createDenTypeId("team") + const teamMemberId = createDenTypeId("teamMember") + const invitation = { + id: invitationId, + email: "teammate@example.com", + role: "member", + organizationId, + status: "pending", + expiresAt: new Date(Date.now() + 60_000), + teamId, + } + const claimedMember = { + id: placeholderMemberId, + organizationId, + userId, + inviteId: invitationId, + invitedByOrgMember: null, + role: "member", + joinedAt: new Date("2026-06-09T00:00:00.000Z"), + removedAt: null, + removedByOrgMember: null, + createdAt: new Date("2026-06-08T00:00:00.000Z"), + } + queryRows = [ + [invitation], + [{ allowedEmailDomains: null }], + [{ role: "member" }, { role: "admin" }], + [], + [{ id: placeholderMemberId }], + [claimedMember], + [{ id: teamId }], + [{ id: teamMemberId }], + ] + + const accepted = await orgsModule.acceptInvitationForUser({ + userId, + email: "teammate@example.com", + invitationId: "invite-token-123", + }) + + expect(accepted?.member.id).toBe(placeholderMemberId) + expect(accepted?.member.userId).toBe(userId) + expect(operations.some((operation) => operation.type === "insert" && operation.value?.userId === userId)).toBe(false) + expect(operations.some((operation) => operation.type === "insert" && operation.value?.orgMembershipId === placeholderMemberId)).toBe(false) + expect(operations.some((operation) => operation.type === "update" && operation.value?.status === "accepted")).toBe(true) + expect(operations.some((operation) => operation.type === "update" && operation.value?.userId === userId)).toBe(true) + expect(hookCalls).toEqual([{ organizationId, memberId: placeholderMemberId, change: "added" }]) +}) + +test("accept by invite token does not emit duplicate member hooks for existing active members", async () => { + const invitationId = createDenTypeId("invitation") + const organizationId = createDenTypeId("organization") + const userId = createDenTypeId("user") + const existingMemberId = createDenTypeId("member") + const invitation = { + id: invitationId, + email: "teammate@example.com", + role: "member", + organizationId, + status: "pending", + expiresAt: new Date(Date.now() + 60_000), + teamId: null, + } + const existingMember = { + id: existingMemberId, + organizationId, + userId, + inviteId: null, + invitedByOrgMember: null, + role: "member", + joinedAt: new Date("2026-06-09T00:00:00.000Z"), + removedAt: null, + removedByOrgMember: null, + createdAt: new Date("2026-06-08T00:00:00.000Z"), + } + queryRows = [ + [invitation], + [{ allowedEmailDomains: null }], + [{ role: "member" }, { role: "admin" }], + [existingMember], + ] + + const accepted = await orgsModule.acceptInvitationForUser({ + userId, + email: "teammate@example.com", + invitationId: "invite-token-123", + }) + + expect(accepted?.member.id).toBe(existingMemberId) + expect(hookCalls).toEqual([]) +}) + +test("member removal targets active members only and repeated removals emit no hook", async () => { + const organizationId = createDenTypeId("organization") + const memberId = createDenTypeId("member") + queryRows = [[]] + + const removed = await orgsModule.removeOrganizationMember({ organizationId, memberId }) + + expect(removed).toBeNull() + expect(operations).toEqual([]) + expect(hookCalls).toEqual([]) + expect(whereInputs.some((input) => conditionReferencesRemovedAt(input))).toBe(true) +}) diff --git a/ee/apps/den-api/test/org-invitations.test.ts b/ee/apps/den-api/test/org-invitations.test.ts index ea09aa1c5b..0a478a62d3 100644 --- a/ee/apps/den-api/test/org-invitations.test.ts +++ b/ee/apps/den-api/test/org-invitations.test.ts @@ -11,12 +11,16 @@ function seedRequiredEnv() { let invitationModule: typeof import("../src/routes/org/invitations.js") let orgRoutesModule: typeof import("../src/routes/org/index.js") +let orgsModule: typeof import("../src/orgs.js") +let authModule: typeof import("../src/auth.js") let userOrganizationsModule: typeof import("../src/middleware/user-organizations.js") beforeAll(async () => { seedRequiredEnv() invitationModule = await import("../src/routes/org/invitations.js") orgRoutesModule = await import("../src/routes/org/index.js") + orgsModule = await import("../src/orgs.js") + authModule = await import("../src/auth.js") userOrganizationsModule = await import("../src/middleware/user-organizations.js") }) @@ -74,6 +78,23 @@ test("invitation cancel still validates against the unscoped handler", async () await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) }) +test("invitation lookup accepts both invitation IDs and invite tokens", () => { + const invitationId = "inv_01j00000000000000000000000" + expect(orgsModule.parseInvitationLookupIdentifier(invitationId)).toEqual({ + invitationId, + inviteToken: invitationId, + }) + expect(orgsModule.parseInvitationLookupIdentifier(" invite-token-123 ")).toEqual({ + invitationId: null, + inviteToken: "invite-token-123", + }) +}) + +test("dev sign-up rate limit remains higher than production", () => { + expect(authModule.getSignUpEmailRateLimitMax(true)).toBe(100) + expect(authModule.getSignUpEmailRateLimitMax(false)).toBe(3) +}) + test("session hydration only runs when a user session is missing an active organization", () => { expect(userOrganizationsModule.shouldHydrateSessionActiveOrganization({ scopedOrganizationId: null,