From ed6bee1bbaada6a99643c0ae80ea1c882d9943de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 20:17:15 +0200 Subject: [PATCH 01/16] feat(den): support Entra SSO auto-join --- ee/apps/den-api/src/auth.ts | 45 ++- ee/apps/den-api/src/entra-sso.ts | 341 +++++++++++++++++ ee/apps/den-api/src/env.ts | 18 +- ee/apps/den-api/src/orgs.ts | 361 ++++++++++++------ ee/apps/den-api/src/routes/org/invitations.ts | 25 +- .../src/routes/org/plugin-system/store.ts | 3 +- ee/apps/den-api/test/entra-sso.test.ts | 334 ++++++++++++++++ .../test/org-invitation-lifecycle.test.ts | 249 ++++++++++++ ee/apps/den-api/test/org-invitations.test.ts | 21 + 9 files changed, 1250 insertions(+), 147 deletions(-) create mode 100644 ee/apps/den-api/src/entra-sso.ts create mode 100644 ee/apps/den-api/test/entra-sso.test.ts create mode 100644 ee/apps/den-api/test/org-invitation-lifecycle.test.ts diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index e7d8a298fc..9aa7f2ffea 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 { deriveDenMcpResource } from "./mcp/resource.js"; import { syncDenSignupContact } from "./loops.js"; @@ -13,7 +14,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"; @@ -77,6 +78,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) { @@ -115,6 +128,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:")); } @@ -133,6 +150,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) => { @@ -215,7 +256,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..89a36665b5 --- /dev/null +++ b/ee/apps/den-api/src/entra-sso.ts @@ -0,0 +1,341 @@ +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 === "[::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 74a4f72acc..4d17ab1ea6 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({ @@ -22,6 +23,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(), @@ -124,6 +133,13 @@ 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) @@ -160,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" @@ -207,6 +222,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..d3605e7a14 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, isNotNull, 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..991a3854ce 100644 --- a/ee/apps/den-api/src/routes/org/invitations.ts +++ b/ee/apps/den-api/src/routes/org/invitations.ts @@ -1,5 +1,5 @@ import { and, eq, gt, isNull } from "@openwork-ee/den-db/drizzle" -import { AuthUserTable, InvitationTable, MemberTable } from "@openwork-ee/den-db/schema" +import { AuthUserTable, InvitationTable, MemberTable, TeamMemberTable } from "@openwork-ee/den-db/schema" import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import type { Hono } from "hono" import { describeRoute } from "hono-openapi" @@ -7,8 +7,7 @@ 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 { isEmailAllowedForOrganization, listAssignableRoles } from "../../orgs.js" import { getOrganizationSeatAddEligibility } from "../../stripe-billing.js" import { DenEmailSendError, sendEmail } from "../../utils/email/send-email.js" import type { OrgRouteVariables } from "./shared.js" @@ -150,8 +149,6 @@ export function registerOrgInvitationRoutes { + await tx + .delete(TeamMemberTable) + .where(eq(TeamMemberTable.orgMembershipId, invitedMemberRows[0].id)) + await tx + .update(MemberTable) + .set({ removedAt: new Date(), removedByOrgMember: payload.currentMember.id, userId: null }) + .where(and(eq(MemberTable.id, invitedMemberRows[0].id), isNull(MemberTable.removedAt))) }) } diff --git a/ee/apps/den-api/src/routes/org/plugin-system/store.ts b/ee/apps/den-api/src/routes/org/plugin-system/store.ts index 669e415cab..1adb64e862 100644 --- a/ee/apps/den-api/src/routes/org/plugin-system/store.ts +++ b/ee/apps/den-api/src/routes/org/plugin-system/store.ts @@ -820,7 +820,7 @@ async function ensureGrantTargetsInOrganization(context: PluginArchActorContext, const member = await db .select({ id: MemberTable.id }) .from(MemberTable) - .where(and(eq(MemberTable.organizationId, organizationId), eq(MemberTable.id, value.orgMembershipId))) + .where(and(eq(MemberTable.organizationId, organizationId), eq(MemberTable.id, value.orgMembershipId), isNull(MemberTable.removedAt))) .limit(1) if (!member[0]) { throw new PluginArchRouteFailure(404, "member_not_found", "Member not found.") @@ -2958,7 +2958,6 @@ async function buildConnectorAutomationContext(input: { connectorInstance: Conne createdAt: member.createdAt, id: member.id, isOwner: roleIncludesOwner(member.role), - joinedAt: member.joinedAt, role: member.role, userId: member.userId, }, 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..e4a21d57cb --- /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://[::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, From 886f297ff95b72636a6311e4d9a8d8dcad55bb2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 20:46:04 +0200 Subject: [PATCH 02/16] fix(den): clean invitation placeholders after auto-join --- ee/apps/den-api/src/orgs.ts | 35 +++++++++++++++++++ .../test/org-invitation-lifecycle.test.ts | 3 ++ 2 files changed, 38 insertions(+) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index d3605e7a14..9aeac4d39b 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -371,6 +371,37 @@ async function claimInvitationPlaceholderMember(input: { return claimedRows[0] ?? null } +async function removeInvitationPlaceholderMember(input: { + invitation: InvitationRow + removedByOrgMemberId: MemberId +}) { + 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 + } + + await db.transaction(async (tx) => { + await tx + .delete(TeamMemberTable) + .where(eq(TeamMemberTable.orgMembershipId, placeholder.id)) + await tx + .update(MemberTable) + .set({ removedAt: new Date(), removedByOrgMember: input.removedByOrgMemberId, userId: null }) + .where(and(eq(MemberTable.id, placeholder.id), isNull(MemberTable.removedAt))) + }) +} + async function ensureInvitationTeamMembership(input: { invitation: InvitationRow memberId: MemberId @@ -589,6 +620,10 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) { let createdMember = false let member = await findActiveMemberForUser({ organizationId: invitation.organizationId, userId }) + if (member) { + await removeInvitationPlaceholderMember({ invitation, removedByOrgMemberId: member.id }) + } + if (!member) { member = await claimInvitationPlaceholderMember({ invitation, userId, role }) createdMember = Boolean(member) diff --git a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts index a9cbfc154d..3e2822c032 100644 --- a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts +++ b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts @@ -197,6 +197,7 @@ test("accept by invite token does not emit duplicate member hooks for existing a const organizationId = createDenTypeId("organization") const userId = createDenTypeId("user") const existingMemberId = createDenTypeId("member") + const placeholderMemberId = createDenTypeId("member") const invitation = { id: invitationId, email: "teammate@example.com", @@ -223,6 +224,7 @@ test("accept by invite token does not emit duplicate member hooks for existing a [{ allowedEmailDomains: null }], [{ role: "member" }, { role: "admin" }], [existingMember], + [{ id: placeholderMemberId }], ] const accepted = await orgsModule.acceptInvitationForUser({ @@ -232,6 +234,7 @@ test("accept by invite token does not emit duplicate member hooks for existing a }) expect(accepted?.member.id).toBe(existingMemberId) + expect(operations.some((operation) => operation.type === "update" && operation.value?.removedAt instanceof Date)).toBe(true) expect(hookCalls).toEqual([]) }) From 199f36b9bccfafd2cebe976c33de8af837281796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 10 Jun 2026 21:08:15 +0200 Subject: [PATCH 03/16] fix(den): reconcile invitations on Entra auto-join --- ee/apps/den-api/src/orgs.ts | 39 ++++++++++++ .../test/org-invitation-lifecycle.test.ts | 60 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 9aeac4d39b..bfce47f609 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -402,6 +402,37 @@ async function removeInvitationPlaceholderMember(input: { }) } +async function reconcilePendingInvitationsForMember(input: { + organizationId: OrgId + memberId: MemberId + userId: UserId +}) { + const userRows = await db + .select({ email: AuthUserTable.email }) + .from(AuthUserTable) + .where(eq(AuthUserTable.id, input.userId)) + .limit(1) + + const email = userRows[0]?.email?.trim() + if (!email) { + return + } + + const invitations = await db + .select() + .from(InvitationTable) + .where(and(eq(InvitationTable.organizationId, input.organizationId), eq(InvitationTable.email, email), eq(InvitationTable.status, "pending"))) + + for (const invitation of invitations) { + await ensureInvitationTeamMembership({ invitation, memberId: input.memberId }) + await removeInvitationPlaceholderMember({ invitation, removedByOrgMemberId: input.memberId }) + await db + .update(InvitationTable) + .set({ status: "accepted" }) + .where(eq(InvitationTable.id, invitation.id)) + } +} + async function ensureInvitationTeamMembership(input: { invitation: InvitationRow memberId: MemberId @@ -610,6 +641,14 @@ export async function ensureEntraSsoMembershipForAccount(input: { }) } + if (result.status === "created" || result.status === "updated" || result.status === "unchanged" || result.status === "owner_preserved") { + await reconcilePendingInvitationsForMember({ + organizationId: result.member.organizationId, + memberId: result.member.id, + userId: input.userId, + }) + } + return result } diff --git a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts index 3e2822c032..ca1b0a40dd 100644 --- a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts +++ b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts @@ -1,12 +1,24 @@ import { beforeAll, beforeEach, expect, mock, test } from "bun:test" import { createDenTypeId } from "@openwork-ee/utils/typeid" +const entraOrganizationId = createDenTypeId("organization") + 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" + process.env.DEN_ENTRA_TENANT_ID = "00000000-0000-0000-0000-000000000123" + process.env.DEN_ENTRA_CLIENT_ID = "client-123" + process.env.DEN_ENTRA_CLIENT_SECRET = "secret-123" + process.env.DEN_ENTRA_AUTO_JOIN_ENABLED = "true" + process.env.DEN_ENTRA_AUTO_JOIN_ORG_ID = entraOrganizationId +} + +function unsignedJwt(payload: Record) { + const encodedPayload = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url") + return `eyJhbGciOiJub25lIn0.${encodedPayload}.` } let queryRows: unknown[][] = [] @@ -238,6 +250,54 @@ test("accept by invite token does not emit duplicate member hooks for existing a expect(hookCalls).toEqual([]) }) +test("Entra auto-join accepts matching pending invitations and removes placeholders", async () => { + const userId = createDenTypeId("user") + const memberId = createDenTypeId("member") + const invitationId = createDenTypeId("invitation") + const placeholderMemberId = createDenTypeId("member") + const member = { + id: memberId, + organizationId: entraOrganizationId, + 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-09T00:00:00.000Z"), + } + const invitation = { + id: invitationId, + email: "teammate@example.com", + role: "member", + organizationId: entraOrganizationId, + status: "pending", + expiresAt: new Date(Date.now() + 60_000), + teamId: null, + } + queryRows = [ + [{ id: entraOrganizationId }], + [], + [], + [member], + [{ email: "teammate@example.com" }], + [invitation], + [{ id: placeholderMemberId }], + ] + + const result = await orgsModule.ensureEntraSsoMembershipForAccount({ + userId, + providerId: "microsoft", + idToken: unsignedJwt({ groups: [] }), + }) + + expect(result.status).toBe("created") + expect(operations.some((operation) => operation.type === "update" && operation.value?.status === "accepted")).toBe(true) + expect(operations.some((operation) => operation.type === "update" && operation.value?.removedAt instanceof Date && operation.value?.userId === null)).toBe(true) + expect(hookCalls).toEqual([{ organizationId: entraOrganizationId, memberId, change: "added" }]) +}) + test("member removal targets active members only and repeated removals emit no hook", async () => { const organizationId = createDenTypeId("organization") const memberId = createDenTypeId("member") From a7d58e2a045c7b5adf147b2e285d9f625db64044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 00:47:41 +0200 Subject: [PATCH 04/16] fix(den): ignore expired invites during Entra reconciliation --- ee/apps/den-api/src/orgs.ts | 4 ++ ee/apps/den-api/src/routes/org/invitations.ts | 48 ++++++++++++++++++- .../test/org-invitation-lifecycle.test.ts | 46 ++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index bfce47f609..a9e71e976f 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -424,6 +424,10 @@ async function reconcilePendingInvitationsForMember(input: { .where(and(eq(InvitationTable.organizationId, input.organizationId), eq(InvitationTable.email, email), eq(InvitationTable.status, "pending"))) for (const invitation of invitations) { + if (getInvitationStatus(invitation) !== "pending") { + continue + } + await ensureInvitationTeamMembership({ invitation, memberId: input.memberId }) await removeInvitationPlaceholderMember({ invitation, removedByOrgMemberId: input.memberId }) await db diff --git a/ee/apps/den-api/src/routes/org/invitations.ts b/ee/apps/den-api/src/routes/org/invitations.ts index 991a3854ce..a2806e8b53 100644 --- a/ee/apps/den-api/src/routes/org/invitations.ts +++ b/ee/apps/den-api/src/routes/org/invitations.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, isNull } from "@openwork-ee/den-db/drizzle" +import { and, eq, gt, isNull, sql } from "@openwork-ee/den-db/drizzle" import { AuthUserTable, InvitationTable, MemberTable, TeamMemberTable } from "@openwork-ee/den-db/schema" import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import type { Hono } from "hono" @@ -50,9 +50,49 @@ const invitePaymentRequiredSchema = z.object({ }).meta({ ref: "InvitePaymentRequiredError" }) type InvitationId = typeof InvitationTable.$inferSelect.id +type OrganizationId = typeof InvitationTable.$inferSelect.organizationId +type MemberId = typeof MemberTable.$inferSelect.id const orgInvitationParamsSchema = idParamSchema("invitationId", "invitation") +async function cleanupExpiredInvitationPlaceholders(input: { + organizationId: OrganizationId + email: string + removedByOrgMemberId: MemberId +}) { + const expiredInvitations = await db + .select({ id: InvitationTable.id }) + .from(InvitationTable) + .where(and( + eq(InvitationTable.organizationId, input.organizationId), + eq(InvitationTable.email, input.email), + eq(InvitationTable.status, "pending"), + sql`${InvitationTable.expiresAt} < ${new Date()}`, + )) + + for (const invitation of expiredInvitations) { + const invitedMemberRows = await db + .select({ id: MemberTable.id }) + .from(MemberTable) + .where(and(eq(MemberTable.inviteId, invitation.id), eq(MemberTable.organizationId, input.organizationId), isNull(MemberTable.joinedAt), isNull(MemberTable.removedAt))) + .limit(1) + + await db.update(InvitationTable).set({ status: "canceled" }).where(eq(InvitationTable.id, invitation.id)) + + if (invitedMemberRows[0]) { + await db.transaction(async (tx) => { + await tx + .delete(TeamMemberTable) + .where(eq(TeamMemberTable.orgMembershipId, invitedMemberRows[0].id)) + await tx + .update(MemberTable) + .set({ removedAt: new Date(), removedByOrgMember: input.removedByOrgMemberId, userId: null }) + .where(and(eq(MemberTable.id, invitedMemberRows[0].id), isNull(MemberTable.removedAt))) + }) + } + } +} + export function registerOrgInvitationRoutes(app: Hono) { app.post( "/v1/invitations", @@ -119,6 +159,12 @@ export function registerOrgInvitationRoutes { + const userId = createDenTypeId("user") + const memberId = createDenTypeId("member") + const invitationId = createDenTypeId("invitation") + const member = { + id: memberId, + organizationId: entraOrganizationId, + 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-09T00:00:00.000Z"), + } + const invitation = { + id: invitationId, + email: "teammate@example.com", + role: "member", + organizationId: entraOrganizationId, + status: "pending", + expiresAt: new Date(Date.now() - 60_000), + teamId: null, + } + queryRows = [ + [{ id: entraOrganizationId }], + [], + [], + [member], + [{ email: "teammate@example.com" }], + [invitation], + ] + + const result = await orgsModule.ensureEntraSsoMembershipForAccount({ + userId, + providerId: "microsoft", + idToken: unsignedJwt({ groups: [] }), + }) + + expect(result.status).toBe("created") + expect(operations.some((operation) => operation.type === "update" && operation.value?.status === "accepted")).toBe(false) + expect(operations.some((operation) => operation.type === "update" && operation.value?.removedAt instanceof Date)).toBe(false) + expect(hookCalls).toEqual([{ organizationId: entraOrganizationId, memberId, change: "added" }]) +}) + test("member removal targets active members only and repeated removals emit no hook", async () => { const organizationId = createDenTypeId("organization") const memberId = createDenTypeId("member") From 30826c4f99010ac683af7c1a1af4173b98e91748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 00:55:27 +0200 Subject: [PATCH 05/16] fix(den): clean expired Entra invite placeholders --- ee/apps/den-api/src/orgs.ts | 5 +++++ ee/apps/den-api/test/org-invitation-lifecycle.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index a9e71e976f..e77a41561b 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -425,6 +425,11 @@ async function reconcilePendingInvitationsForMember(input: { for (const invitation of invitations) { if (getInvitationStatus(invitation) !== "pending") { + await removeInvitationPlaceholderMember({ invitation, removedByOrgMemberId: input.memberId }) + await db + .update(InvitationTable) + .set({ status: "canceled" }) + .where(eq(InvitationTable.id, invitation.id)) continue } diff --git a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts index 0cece42eed..0cfade7831 100644 --- a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts +++ b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts @@ -302,6 +302,7 @@ test("Entra auto-join ignores expired pending invitations", async () => { const userId = createDenTypeId("user") const memberId = createDenTypeId("member") const invitationId = createDenTypeId("invitation") + const placeholderMemberId = createDenTypeId("member") const member = { id: memberId, organizationId: entraOrganizationId, @@ -330,6 +331,7 @@ test("Entra auto-join ignores expired pending invitations", async () => { [member], [{ email: "teammate@example.com" }], [invitation], + [{ id: placeholderMemberId }], ] const result = await orgsModule.ensureEntraSsoMembershipForAccount({ @@ -340,7 +342,8 @@ test("Entra auto-join ignores expired pending invitations", async () => { expect(result.status).toBe("created") expect(operations.some((operation) => operation.type === "update" && operation.value?.status === "accepted")).toBe(false) - expect(operations.some((operation) => operation.type === "update" && operation.value?.removedAt instanceof Date)).toBe(false) + expect(operations.some((operation) => operation.type === "update" && operation.value?.status === "canceled")).toBe(true) + expect(operations.some((operation) => operation.type === "update" && operation.value?.removedAt instanceof Date)).toBe(true) expect(hookCalls).toEqual([{ organizationId: entraOrganizationId, memberId, change: "added" }]) }) From 4cfda2e8c73f1efceafe6810650807cddb2b5a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 01:00:37 +0200 Subject: [PATCH 06/16] fix(den): reconcile Entra invites before member hooks --- ee/apps/den-api/src/orgs.ts | 12 ++++++------ .../den-api/src/routes/org/plugin-system/store.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index e77a41561b..24f78ff098 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -642,19 +642,19 @@ export async function ensureEntraSsoMembershipForAccount(input: { }, }) - if (result.status === "created") { - await runPostOrganizationMemberChangeHooks({ + if (result.status === "created" || result.status === "updated" || result.status === "unchanged" || result.status === "owner_preserved") { + await reconcilePendingInvitationsForMember({ organizationId: result.member.organizationId, memberId: result.member.id, - change: "added", + userId: input.userId, }) } - if (result.status === "created" || result.status === "updated" || result.status === "unchanged" || result.status === "owner_preserved") { - await reconcilePendingInvitationsForMember({ + if (result.status === "created") { + await runPostOrganizationMemberChangeHooks({ organizationId: result.member.organizationId, memberId: result.member.id, - userId: input.userId, + change: "added", }) } diff --git a/ee/apps/den-api/src/routes/org/plugin-system/store.ts b/ee/apps/den-api/src/routes/org/plugin-system/store.ts index 1adb64e862..03d732ab9f 100644 --- a/ee/apps/den-api/src/routes/org/plugin-system/store.ts +++ b/ee/apps/den-api/src/routes/org/plugin-system/store.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, isNull } from "@openwork-ee/den-db/drizzle" +import { and, asc, desc, eq, inArray, isNotNull, isNull } from "@openwork-ee/den-db/drizzle" import { ConfigObjectAccessGrantTable, ConfigObjectTable, @@ -820,7 +820,7 @@ async function ensureGrantTargetsInOrganization(context: PluginArchActorContext, const member = await db .select({ id: MemberTable.id }) .from(MemberTable) - .where(and(eq(MemberTable.organizationId, organizationId), eq(MemberTable.id, value.orgMembershipId), isNull(MemberTable.removedAt))) + .where(and(eq(MemberTable.organizationId, organizationId), eq(MemberTable.id, value.orgMembershipId), isNotNull(MemberTable.userId), isNull(MemberTable.removedAt))) .limit(1) if (!member[0]) { throw new PluginArchRouteFailure(404, "member_not_found", "Member not found.") From 7e7ed0dfee6a7e5de95ebd96f958a18113d07b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 01:05:53 +0200 Subject: [PATCH 07/16] fix(den): preserve organization member lifecycle fields --- ee/apps/den-api/src/orgs.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 24f78ff098..26ca213559 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -1078,7 +1078,9 @@ export async function getOrganizationContextForUser(input: { .select({ id: MemberTable.id, userId: AuthUserTable.id, + inviteId: MemberTable.inviteId, role: MemberTable.role, + joinedAt: MemberTable.joinedAt, createdAt: MemberTable.createdAt, user: { id: AuthUserTable.id, @@ -1098,6 +1100,7 @@ export async function getOrganizationContextForUser(input: { email: InvitationTable.email, role: InvitationTable.role, status: InvitationTable.status, + inviteToken: InvitationTable.inviteToken, expiresAt: InvitationTable.expiresAt, createdAt: InvitationTable.createdAt, }) @@ -1136,7 +1139,9 @@ export async function getOrganizationContextForUser(input: { members: members.map((member) => ({ id: member.id, userId: member.user.id, + inviteId: member.inviteId, role: member.role, + joinedAt: member.joinedAt, createdAt: member.createdAt, user: member.user, isOwner: roleIncludesOwner(member.role), From 772702a3f8ebbe7665cf99f731bb19d701cb8296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 01:13:40 +0200 Subject: [PATCH 08/16] fix(den): keep pending invite members in org context --- ee/apps/den-api/src/orgs.ts | 42 +++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 26ca213559..692bdd0c3f 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -80,12 +80,14 @@ export type OrganizationContext = { } members: Array<{ id: MemberId - userId: UserId + userId: UserId | null + inviteId: InvitationRow["id"] | null role: string + joinedAt: Date | null createdAt: Date isOwner: boolean user: { - id: UserId + id: UserId | MemberId | InvitationRow["id"] email: string name: string image: string | null @@ -96,6 +98,7 @@ export type OrganizationContext = { email: string role: string status: string + inviteToken: string | null expiresAt: Date createdAt: Date }> @@ -1082,6 +1085,7 @@ export async function getOrganizationContextForUser(input: { role: MemberTable.role, joinedAt: MemberTable.joinedAt, createdAt: MemberTable.createdAt, + invitationEmail: InvitationTable.email, user: { id: AuthUserTable.id, email: AuthUserTable.email, @@ -1090,7 +1094,8 @@ export async function getOrganizationContextForUser(input: { }, }) .from(MemberTable) - .innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) + .leftJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) + .leftJoin(InvitationTable, eq(MemberTable.inviteId, InvitationTable.id)) .where(and(eq(MemberTable.organizationId, organization.id), isNull(MemberTable.removedAt))) .orderBy(asc(MemberTable.createdAt)) @@ -1136,16 +1141,27 @@ export async function getOrganizationContextForUser(input: { createdAt: currentMember.createdAt, isOwner: roleIncludesOwner(currentMember.role), }, - members: members.map((member) => ({ - id: member.id, - userId: member.user.id, - inviteId: member.inviteId, - role: member.role, - joinedAt: member.joinedAt, - createdAt: member.createdAt, - user: member.user, - isOwner: roleIncludesOwner(member.role), - })), + members: members.map((member) => { + const invitationEmail = member.invitationEmail ?? "" + const user = member.user?.id + ? member.user + : { + id: member.inviteId ?? member.id, + email: invitationEmail, + name: invitationEmail, + image: null, + } + return { + id: member.id, + userId: member.user?.id ?? null, + inviteId: member.inviteId, + role: member.role, + joinedAt: member.joinedAt, + createdAt: member.createdAt, + user, + isOwner: roleIncludesOwner(member.role), + } + }), invitations, roles: [ { From fbb6d2499417dea007fcb042a0a607681d5bd95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 01:24:22 +0200 Subject: [PATCH 09/16] fix(den): expose Microsoft SSO in Den Web --- .../app/(den)/_components/auth-panel.tsx | 19 +++++++++++++++++++ .../app/(den)/_components/join-org-screen.tsx | 1 - ee/apps/den-web/app/(den)/_lib/den-flow.ts | 6 ++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/ee/apps/den-web/app/(den)/_components/auth-panel.tsx b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx index f94ebae014..fe6ae0217c 100644 --- a/ee/apps/den-web/app/(den)/_components/auth-panel.tsx +++ b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx @@ -49,6 +49,17 @@ function GoogleLogo() { ); } +function MicrosoftLogo() { + return ( + + ); +} + function SocialButton({ children, onClick, @@ -357,6 +368,14 @@ export function AuthPanel({ Continue with Google + void beginSocialAuth("microsoft")} + disabled={authBusy || desktopRedirectBusy} + > + + Continue with Microsoft + + diff --git a/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx b/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx index 3de6ba99a3..018e541d65 100644 --- a/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx @@ -264,7 +264,6 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) { prefillKey={preview.invitation.id} initialMode="sign-up" lockEmail - hideSocialAuth hideEmailField signUpContent={{ title: `Join ${preview.organization.name}.`, diff --git a/ee/apps/den-web/app/(den)/_lib/den-flow.ts b/ee/apps/den-web/app/(den)/_lib/den-flow.ts index 3cb38f61bc..3640642809 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-flow.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-flow.ts @@ -1,7 +1,7 @@ import { DEN_WORKER_POLL_INTERVAL_MS } from "./CONSTS"; export type AuthMode = "sign-in" | "sign-up"; -export type SocialAuthProvider = "github" | "google"; +export type SocialAuthProvider = "github" | "google" | "microsoft"; export type WorkerStatusBucket = "ready" | "starting" | "attention" | "other"; export type RuntimeServiceName = "openwork-server" | "opencode" | "opencode-router"; export type EventLevel = "info" | "success" | "warning" | "error"; @@ -232,7 +232,9 @@ export function normalizeAuthModeParam(value: string | null | undefined): AuthMo } export function getSocialProviderLabel(provider: SocialAuthProvider): string { - return provider === "github" ? "GitHub" : "Google"; + if (provider === "github") return "GitHub"; + if (provider === "google") return "Google"; + return "Microsoft"; } export function normalizeWorkerName(input: string): string { From f0199f094f256a3c19c33a7463ff863b9faa30dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 01:32:44 +0200 Subject: [PATCH 10/16] fix(den): gate Entra auto-join seat additions --- ee/apps/den-api/src/orgs.ts | 19 ++++++--- .../test/org-invitation-lifecycle.test.ts | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 692bdd0c3f..fb3c70e171 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -18,6 +18,7 @@ import { runPostOrganizationMemberChangeHooks } from "./organization-member-hook import { DEFAULT_ORGANIZATION_LIMITS, normalizeOrganizationMetadata, serializeOrganizationMetadata } from "./organization-limits.js" import { denDefaultDynamicOrganizationRoles, denOrganizationStaticRoles } from "./organization-access.js" import { ensureDefaultDesktopPolicyForOrganization } from "./desktop-policies.js" +import { getOrganizationSeatAddEligibility } from "./stripe-billing.js" type UserId = typeof AuthUserTable.$inferSelect.id type SessionId = typeof AuthSessionTable.$inferSelect.id @@ -619,11 +620,19 @@ export async function ensureEntraSsoMembershipForAccount(input: { return existing[0] ?? null }, - createMember: async ({ organizationId, userId, role }) => insertMemberIfMissing({ - organizationId: organizationId as OrgId, - userId: userId as UserId, - role, - }), + createMember: async ({ organizationId, userId, role }) => { + const normalizedOrganizationId = organizationId as OrgId + const seatEligibility = await getOrganizationSeatAddEligibility(normalizedOrganizationId) + if (!seatEligibility.allowed) { + throw new Error("entra_sso_seat_subscription_required") + } + + return insertMemberIfMissing({ + organizationId: normalizedOrganizationId, + userId: userId as UserId, + role, + }) + }, updateMemberRole: async ({ memberId, role }) => { await db .update(MemberTable) diff --git a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts index 0cfade7831..a9e8defc5f 100644 --- a/ee/apps/den-api/test/org-invitation-lifecycle.test.ts +++ b/ee/apps/den-api/test/org-invitation-lifecycle.test.ts @@ -25,6 +25,7 @@ let queryRows: unknown[][] = [] let operations: Array<{ type: "insert" | "update"; value: any }> = [] let hookCalls: Array<{ organizationId: string; memberId: string; change: "added" | "removed" }> = [] let whereInputs: unknown[] = [] +let seatEligibility = { allowed: true, currentCount: 1, freeSeatCount: 1 } function queryFor(rows: unknown[]) { const chain: any = { @@ -107,6 +108,28 @@ mock.module("../src/organization-member-hooks.js", () => ({ }, })) +mock.module("../src/stripe-billing.js", () => ({ + FREE_ORG_SEAT_COUNT: 5, + billableSeatQuantity: (memberCount: number) => Math.max(0, memberCount - 5), + createInferenceCheckoutSession: () => Promise.resolve(null), + createInferencePortalSession: () => Promise.resolve(null), + createOrgSubscriptionCheckoutSession: () => Promise.resolve(null), + createSeatCheckoutSession: () => Promise.resolve(null), + createStripePortalSession: () => Promise.resolve(null), + findOrCreateStripeCustomer: () => Promise.resolve(null), + getActiveMemberCountForBilling: () => Promise.resolve(seatEligibility.currentCount), + getOrgBillingSummary: () => Promise.resolve(null), + getOrganizationSeatAddEligibility: () => Promise.resolve(seatEligibility), + handleStripeWebhook: () => Promise.resolve({ received: true }), + organizationHasActiveInferenceSubscription: () => Promise.resolve(false), + organizationHasActiveSeatSubscription: () => Promise.resolve(seatEligibility.allowed), + syncInferenceSubscriptionQuantityAfterMemberChange: () => Promise.resolve(), + syncSeatCheckoutSession: () => Promise.resolve(null), + syncSeatSubscriptionQuantityAfterMemberChange: () => Promise.resolve(), + upsertInferenceSubscriptionFromStripe: () => Promise.resolve(), + upsertOrgSubscriptionFromStripe: () => Promise.resolve(), +})) + let orgsModule: typeof import("../src/orgs.js") beforeAll(async () => { @@ -119,6 +142,7 @@ beforeEach(() => { operations = [] hookCalls = [] whereInputs = [] + seatEligibility = { allowed: true, currentCount: 1, freeSeatCount: 1 } }) test("invitation preview resolves an invite token", async () => { @@ -347,6 +371,24 @@ test("Entra auto-join ignores expired pending invitations", async () => { expect(hookCalls).toEqual([{ organizationId: entraOrganizationId, memberId, change: "added" }]) }) +test("Entra auto-join does not bypass seat billing gate", async () => { + const userId = createDenTypeId("user") + seatEligibility = { allowed: false, currentCount: 1, freeSeatCount: 1 } + queryRows = [ + [{ id: entraOrganizationId }], + [], + ] + + await expect(orgsModule.ensureEntraSsoMembershipForAccount({ + userId, + providerId: "microsoft", + idToken: unsignedJwt({ groups: [] }), + })).rejects.toThrow("entra_sso_seat_subscription_required") + + expect(operations).toEqual([]) + 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") From db5d19341e2ce3dbc44f64ee6c1378cd1a743c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 11 Jun 2026 01:46:19 +0200 Subject: [PATCH 11/16] fix(den): complete Entra SSO lifecycle coverage --- .../den-api/src/organization-member-hooks.ts | 6 ++ ee/apps/den-api/src/orgs.ts | 44 +++++++++++++- ee/apps/den-api/src/routes/auth/index.ts | 28 ++++++++- ee/apps/den-api/src/routes/org/invitations.ts | 5 ++ .../test/org-invitation-lifecycle.test.ts | 59 +++++++++++++++++++ .../app/(den)/_components/auth-panel.tsx | 52 +++++++++------- ee/apps/den-web/app/(den)/_lib/den-flow.ts | 14 +++++ .../(den)/_providers/den-flow-provider.tsx | 27 ++++++++- 8 files changed, 208 insertions(+), 27 deletions(-) diff --git a/ee/apps/den-api/src/organization-member-hooks.ts b/ee/apps/den-api/src/organization-member-hooks.ts index ce27b93a2e..367734bf8e 100644 --- a/ee/apps/den-api/src/organization-member-hooks.ts +++ b/ee/apps/den-api/src/organization-member-hooks.ts @@ -42,3 +42,9 @@ export async function runPostOrganizationMemberChangeHooks(input: { await hook({ ...input, memberCount }) } } + +export async function syncOrganizationMemberBillingQuantities(input: { organizationId: OrgId }) { + const memberCount = await countOrganizationMembers(input.organizationId) + await syncSeatSubscriptionQuantityAfterMemberChange({ organizationId: input.organizationId, memberCount }) + await syncInferenceSubscriptionQuantityAfterMemberChange({ organizationId: input.organizationId, memberCount }) +} diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index fb3c70e171..ea9e7e2dc8 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -1,4 +1,4 @@ -import { and, asc, count, desc, eq, inArray, isNotNull, isNull, or } from "@openwork-ee/den-db/drizzle" +import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, or } from "@openwork-ee/den-db/drizzle" import { AuthSessionTable, AuthAccountTable, @@ -446,6 +446,40 @@ async function reconcilePendingInvitationsForMember(input: { } } +async function hasPendingInvitationSeatReservation(input: { + organizationId: OrgId + userId: UserId +}) { + const userRows = await db + .select({ email: AuthUserTable.email }) + .from(AuthUserTable) + .where(eq(AuthUserTable.id, input.userId)) + .limit(1) + + const email = userRows[0]?.email?.trim() + if (!email) { + return false + } + + const rows = await db + .select({ id: MemberTable.id }) + .from(InvitationTable) + .innerJoin(MemberTable, eq(MemberTable.inviteId, InvitationTable.id)) + .where(and( + eq(InvitationTable.organizationId, input.organizationId), + eq(InvitationTable.email, email), + eq(InvitationTable.status, "pending"), + gt(InvitationTable.expiresAt, new Date()), + eq(MemberTable.organizationId, input.organizationId), + isNull(MemberTable.userId), + isNull(MemberTable.joinedAt), + isNull(MemberTable.removedAt), + )) + .limit(1) + + return Boolean(rows[0]) +} + async function ensureInvitationTeamMembership(input: { invitation: InvitationRow memberId: MemberId @@ -622,14 +656,18 @@ export async function ensureEntraSsoMembershipForAccount(input: { }, createMember: async ({ organizationId, userId, role }) => { const normalizedOrganizationId = organizationId as OrgId + const normalizedUserId = userId as UserId const seatEligibility = await getOrganizationSeatAddEligibility(normalizedOrganizationId) - if (!seatEligibility.allowed) { + if (!seatEligibility.allowed && !await hasPendingInvitationSeatReservation({ + organizationId: normalizedOrganizationId, + userId: normalizedUserId, + })) { throw new Error("entra_sso_seat_subscription_required") } return insertMemberIfMissing({ organizationId: normalizedOrganizationId, - userId: userId as UserId, + userId: normalizedUserId, role, }) }, diff --git a/ee/apps/den-api/src/routes/auth/index.ts b/ee/apps/den-api/src/routes/auth/index.ts index c5b59f2819..c983025b37 100644 --- a/ee/apps/den-api/src/routes/auth/index.ts +++ b/ee/apps/den-api/src/routes/auth/index.ts @@ -3,10 +3,12 @@ import { OAuthClientTable } from "@openwork-ee/den-db/schema" import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider" import type { Hono } from "hono" import { describeRoute } from "hono-openapi" +import { z } from "zod" import { auth } from "../../auth.js" import { db } from "../../db.js" +import { isEntraSsoEnabled } from "../../entra-sso.js" import { env } from "../../env.js" -import { emptyResponse } from "../../openapi.js" +import { emptyResponse, jsonResponse } from "../../openapi.js" import type { AuthContextVariables } from "../../session.js" import { registerDesktopAuthRoutes } from "./desktop-handoff.js" import { registerScimAuthRoutes } from "./scim.js" @@ -81,6 +83,18 @@ function readStoredClientScopes(scopes: string | null) { return scopes.split(/\s+/).filter(Boolean) } +const authProvidersSchema = z.object({ + socialProviders: z.array(z.enum(["github", "google", "microsoft"])), +}).meta({ ref: "AuthProviders" }) + +function getConfiguredSocialProviders() { + return [ + env.github.clientId && env.github.clientSecret ? "github" : null, + env.google.clientId && env.google.clientSecret ? "google" : null, + isEntraSsoEnabled(env.entra) ? "microsoft" : null, + ].filter((provider): provider is "github" | "google" | "microsoft" => provider !== null) +} + async function ensureMcpClientScopes(request: Request) { const url = new URL(request.url) const requestedScopes = new Set((url.searchParams.get("scope") ?? "").split(/\s+/).filter(Boolean)) @@ -122,6 +136,18 @@ async function ensureMcpClientScopes(request: Request) { export function registerAuthRoutes(app: Hono) { registerScimAuthRoutes(app) + app.get( + "/v1/auth/providers", + describeRoute({ + tags: ["Authentication"], + summary: "List configured authentication providers", + description: "Returns the social authentication providers currently configured for this Den deployment.", + responses: { + 200: jsonResponse("Configured authentication providers.", authProvidersSchema), + }, + }), + (c) => c.json({ socialProviders: getConfiguredSocialProviders() }), + ) app.get("/api/auth/.well-known/oauth-authorization-server", async (c) => rewriteMetadataOrigin(await oauthProviderAuthServerMetadata(auth)(c.req.raw), requestOrigin(c.req.raw))) app.get("/api/auth/.well-known/openid-configuration", async (c) => rewriteMetadataOrigin(await oauthProviderOpenIdConfigMetadata(auth)(c.req.raw), requestOrigin(c.req.raw))) app.get("/.well-known/oauth-authorization-server/api/auth", async (c) => rewriteMetadataOrigin(await oauthProviderAuthServerMetadata(auth)(c.req.raw), requestOrigin(c.req.raw))) diff --git a/ee/apps/den-api/src/routes/org/invitations.ts b/ee/apps/den-api/src/routes/org/invitations.ts index a2806e8b53..02e77eba0b 100644 --- a/ee/apps/den-api/src/routes/org/invitations.ts +++ b/ee/apps/den-api/src/routes/org/invitations.ts @@ -7,6 +7,7 @@ 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 { syncOrganizationMemberBillingQuantities } from "../../organization-member-hooks.js" import { isEmailAllowedForOrganization, listAssignableRoles } from "../../orgs.js" import { getOrganizationSeatAddEligibility } from "../../stripe-billing.js" import { DenEmailSendError, sendEmail } from "../../utils/email/send-email.js" @@ -89,6 +90,7 @@ async function cleanupExpiredInvitationPlaceholders(input: { .set({ removedAt: new Date(), removedByOrgMember: input.removedByOrgMemberId, userId: null }) .where(and(eq(MemberTable.id, invitedMemberRows[0].id), isNull(MemberTable.removedAt))) }) + await syncOrganizationMemberBillingQuantities({ organizationId: input.organizationId }) } } } @@ -223,6 +225,7 @@ export function registerOrgInvitationRoutes = [] let hookCalls: Array<{ organizationId: string; memberId: string; change: "added" | "removed" }> = [] let whereInputs: unknown[] = [] let seatEligibility = { allowed: true, currentCount: 1, freeSeatCount: 1 } +let billingSyncCalls: Array<{ organizationId: string }> = [] function queryFor(rows: unknown[]) { const chain: any = { @@ -106,6 +107,10 @@ mock.module("../src/organization-member-hooks.js", () => ({ hookCalls.push(input) return Promise.resolve() }, + syncOrganizationMemberBillingQuantities: (input: { organizationId: string }) => { + billingSyncCalls.push(input) + return Promise.resolve() + }, })) mock.module("../src/stripe-billing.js", () => ({ @@ -143,6 +148,7 @@ beforeEach(() => { hookCalls = [] whereInputs = [] seatEligibility = { allowed: true, currentCount: 1, freeSeatCount: 1 } + billingSyncCalls = [] }) test("invitation preview resolves an invite token", async () => { @@ -377,6 +383,8 @@ test("Entra auto-join does not bypass seat billing gate", async () => { queryRows = [ [{ id: entraOrganizationId }], [], + [{ email: "teammate@example.com" }], + [], ] await expect(orgsModule.ensureEntraSsoMembershipForAccount({ @@ -389,6 +397,57 @@ test("Entra auto-join does not bypass seat billing gate", async () => { expect(hookCalls).toEqual([]) }) +test("Entra auto-join accepts an already reserved invite seat", async () => { + const userId = createDenTypeId("user") + const memberId = createDenTypeId("member") + const invitationId = createDenTypeId("invitation") + const placeholderMemberId = createDenTypeId("member") + const member = { + id: memberId, + organizationId: entraOrganizationId, + 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-09T00:00:00.000Z"), + } + const invitation = { + id: invitationId, + email: "teammate@example.com", + role: "member", + organizationId: entraOrganizationId, + status: "pending", + expiresAt: new Date(Date.now() + 60_000), + teamId: null, + } + seatEligibility = { allowed: false, currentCount: 5, freeSeatCount: 5 } + queryRows = [ + [{ id: entraOrganizationId }], + [], + [{ email: "teammate@example.com" }], + [{ id: placeholderMemberId }], + [], + [member], + [{ email: "teammate@example.com" }], + [invitation], + [{ id: placeholderMemberId }], + ] + + const result = await orgsModule.ensureEntraSsoMembershipForAccount({ + userId, + providerId: "microsoft", + idToken: unsignedJwt({ groups: [] }), + }) + + expect(result.status).toBe("created") + expect(operations.some((operation) => operation.type === "insert" && operation.value?.userId === userId)).toBe(true) + expect(operations.some((operation) => operation.type === "update" && operation.value?.status === "accepted")).toBe(true) + expect(hookCalls).toEqual([{ organizationId: entraOrganizationId, memberId, change: "added" }]) +}) + test("member removal targets active members only and repeated removals emit no hook", async () => { const organizationId = createDenTypeId("organization") const memberId = createDenTypeId("member") diff --git a/ee/apps/den-web/app/(den)/_components/auth-panel.tsx b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx index fe6ae0217c..da29331c76 100644 --- a/ee/apps/den-web/app/(den)/_components/auth-panel.tsx +++ b/ee/apps/den-web/app/(den)/_components/auth-panel.tsx @@ -134,6 +134,7 @@ export function AuthPanel({ resendVerificationCode, cancelVerification, beginSocialAuth, + socialAuthProviders, resolveUserLandingRoute, } = useDenFlow(); @@ -179,6 +180,7 @@ export function AuthPanel({ ? resolvedSignInContent : resolvedSignUpContent; const showLockedEmailSummary = Boolean(prefilledEmail && lockEmail && hideEmailField); + const showSocialAuth = !verificationRequired && !isPasswordResetRequest && !hideSocialAuth && socialAuthProviders.length > 0; useEffect(() => { const key = prefillKey ?? prefilledEmail?.trim() ?? null; @@ -350,31 +352,37 @@ export function AuthPanel({ } }} > - {!verificationRequired && !isPasswordResetRequest && !hideSocialAuth ? ( + {showSocialAuth ? ( <> - void beginSocialAuth("github")} - disabled={authBusy || desktopRedirectBusy} - > - - Continue with GitHub - + {socialAuthProviders.includes("github") ? ( + void beginSocialAuth("github")} + disabled={authBusy || desktopRedirectBusy} + > + + Continue with GitHub + + ) : null} - void beginSocialAuth("google")} - disabled={authBusy || desktopRedirectBusy} - > - - Continue with Google - + {socialAuthProviders.includes("google") ? ( + void beginSocialAuth("google")} + disabled={authBusy || desktopRedirectBusy} + > + + Continue with Google + + ) : null} - void beginSocialAuth("microsoft")} - disabled={authBusy || desktopRedirectBusy} - > - - Continue with Microsoft - + {socialAuthProviders.includes("microsoft") ? ( + void beginSocialAuth("microsoft")} + disabled={authBusy || desktopRedirectBusy} + > + + Continue with Microsoft + + ) : null}