From f7ebab24361076fc9f711fe338c1ef3b60820c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 22 May 2026 09:20:45 +0200 Subject: [PATCH] feat(den): support Entra SSO auto-join --- ee/apps/den-api/src/auth.ts | 41 ++- ee/apps/den-api/src/entra-sso.ts | 340 +++++++++++++++++++++++++ ee/apps/den-api/src/env.ts | 19 +- ee/apps/den-api/src/orgs.ts | 229 +++++++++-------- ee/apps/den-api/test/entra-sso.test.ts | 334 ++++++++++++++++++++++++ 5 files changed, 851 insertions(+), 112 deletions(-) create mode 100644 ee/apps/den-api/src/entra-sso.ts create mode 100644 ee/apps/den-api/test/entra-sso.test.ts diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index 396c97417e..d912acd376 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"; @@ -70,6 +71,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) { @@ -112,6 +125,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) => { @@ -186,7 +223,7 @@ export const auth = betterAuth({ }, "/sign-up/email": { window: 3600, - max: env.devMode ? 100 : 5, + max: 3, }, "/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 3f74edbdde..72b822d387 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(), RESEND_API_KEY: z.string().optional(), SMTP_HOST: z.string().optional(), @@ -121,6 +130,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) @@ -154,7 +171,6 @@ const polarFeatureGateEnabled = const devMode = (parsed.OPENWORK_DEV_MODE ?? "0").trim() === "1" const port = Number(parsed.PORT ?? "8790") - const daytonaSandboxPublic = (parsed.DAYTONA_SANDBOX_PUBLIC ?? "false").toLowerCase() === "true" @@ -196,6 +212,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 51df8242e3..cea6ec0276 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -1,6 +1,7 @@ -import { and, asc, eq, inArray, isNull } from "@openwork-ee/den-db/drizzle" +import { and, asc, desc, eq, inArray } 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" @@ -72,19 +75,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 @@ -97,7 +97,6 @@ export type OrganizationContext = { status: string expiresAt: Date createdAt: Date - inviteToken: string | null }> roles: Array<{ id: string @@ -214,20 +213,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 @@ -297,7 +282,7 @@ async function listMembershipRows(userId: UserId) { return db .select() .from(MemberTable) - .where(and(eq(MemberTable.userId, userId), isNull(MemberTable.removedAt))) + .where(eq(MemberTable.userId, userId)) .orderBy(asc(MemberTable.createdAt)) } @@ -310,16 +295,6 @@ function getInvitationStatus(invitation: Pick 0) { @@ -394,13 +369,12 @@ async function insertMemberIfMissing(input: { organizationId: input.organizationId, userId: input.userId, role: input.role, - joinedAt: new Date(), }) const created = await db .select() .from(MemberTable) - .where(and(eq(MemberTable.organizationId, input.organizationId), eq(MemberTable.userId, input.userId), isNull(MemberTable.removedAt))) + .where(and(eq(MemberTable.organizationId, input.organizationId), eq(MemberTable.userId, input.userId))) .limit(1) if (!created[0]) { @@ -410,42 +384,108 @@ 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) + return 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))) + .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 (!member) { - member = await insertMemberIfMissing({ - organizationId: invitation.organizationId, - userId, - role, - }) - } +async function acceptInvitation(invitation: InvitationRow, userId: UserId) { + const availableRoles = await listAssignableRoles(invitation.organizationId) + const role = normalizeAssignableRole(invitation.role, availableRoles) + + const member = await insertMemberIfMissing({ + organizationId: invitation.organizationId, + userId, + role, + }) if (invitation.teamId) { const teams = await db @@ -522,8 +562,10 @@ export async function acceptInvitationForUser(input: { } export async function getInvitationPreview(invitationIdRaw: string): Promise { - const invitation = await getInvitationById(invitationIdRaw) - if (!invitation) { + let invitationId + try { + invitationId = normalizeDenTypeId("invitation", invitationIdRaw) + } catch { return null } @@ -546,7 +588,7 @@ export async function getInvitationPreview(invitationIdRaw: string): Promise ({ @@ -826,7 +868,7 @@ export async function getOrganizationContextForUser(input: { const currentMemberRows = await db .select() .from(MemberTable) - .where(and(eq(MemberTable.organizationId, organization.id), eq(MemberTable.userId, input.userId), isNull(MemberTable.removedAt))) + .where(and(eq(MemberTable.organizationId, organization.id), eq(MemberTable.userId, input.userId))) .limit(1) const currentMember = currentMemberRows[0] @@ -834,34 +876,24 @@ export async function getOrganizationContextForUser(input: { return null } - if (!currentMember.userId) { - return null - } - await ensureDefaultDynamicRoles(organization.id) const members = await db .select({ id: MemberTable.id, userId: MemberTable.userId, - inviteId: MemberTable.inviteId, role: MemberTable.role, createdAt: MemberTable.createdAt, - joinedAt: MemberTable.joinedAt, user: { id: AuthUserTable.id, email: AuthUserTable.email, name: AuthUserTable.name, image: AuthUserTable.image, }, - invitation: { - email: InvitationTable.email, - }, }) .from(MemberTable) - .leftJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) - .leftJoin(InvitationTable, eq(MemberTable.inviteId, InvitationTable.id)) - .where(and(eq(MemberTable.organizationId, organization.id), isNull(MemberTable.removedAt))) + .innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) + .where(eq(MemberTable.organizationId, organization.id)) .orderBy(asc(MemberTable.createdAt)) const invitations = await db @@ -872,7 +904,6 @@ export async function getOrganizationContextForUser(input: { status: InvitationTable.status, expiresAt: InvitationTable.expiresAt, createdAt: InvitationTable.createdAt, - inviteToken: InvitationTable.inviteToken, }) .from(InvitationTable) .where(eq(InvitationTable.organizationId, organization.id)) @@ -904,28 +935,12 @@ export async function getOrganizationContextForUser(input: { userId: currentMember.userId, role: currentMember.role, createdAt: currentMember.createdAt, - joinedAt: currentMember.joinedAt, isOwner: roleIncludesOwner(currentMember.role), }, - members: members.map((member) => { - 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) => ({ + ...member, + isOwner: roleIncludesOwner(member.role), + })), invitations, roles: [ { @@ -1009,12 +1024,11 @@ export async function listTeamsForMember(input: { export async function removeOrganizationMember(input: { organizationId: OrgId memberId: MemberRow["id"] - removedByOrgMemberId?: MemberRow["id"] }) { const memberRows = await db .select() .from(MemberTable) - .where(and(eq(MemberTable.id, input.memberId), eq(MemberTable.organizationId, input.organizationId), isNull(MemberTable.removedAt))) + .where(and(eq(MemberTable.id, input.memberId), eq(MemberTable.organizationId, input.organizationId))) .limit(1) const member = memberRows[0] ?? null @@ -1034,10 +1048,7 @@ export async function removeOrganizationMember(input: { .where(and(eq(TeamMemberTable.teamId, team.id), eq(TeamMemberTable.orgMembershipId, member.id))) } - await tx - .update(MemberTable) - .set({ removedAt: new Date(), removedByOrgMember: input.removedByOrgMemberId ?? null }) - .where(eq(MemberTable.id, member.id)) + await tx.delete(MemberTable).where(eq(MemberTable.id, member.id)) }) await runPostOrganizationMemberChangeHooks({ organizationId: input.organizationId, memberId: member.id, change: "removed" }) diff --git a/ee/apps/den-api/test/entra-sso.test.ts b/ee/apps/den-api/test/entra-sso.test.ts new file mode 100644 index 0000000000..fd87e1848d --- /dev/null +++ b/ee/apps/den-api/test/entra-sso.test.ts @@ -0,0 +1,334 @@ +import { expect, test } from "bun:test" +import { + extractEntraGroupsFromIdToken, + ensureEntraSsoMembership, + type EntraSsoMembershipRecord, + isEntraSsoEnabled, + mapEntraProfileToUser, + normalizeEntraTenantId, + normalizeSsoAssignableRole, + parseEntraSsoEnv, + resolveEntraSsoRole, + validateEntraSsoEnv, +} from "../src/entra-sso.js" + +function unsignedJwt(payload: Record) { + 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) +})