From fd91c5da791e5eff0c61d3e38293fe39c8da3d4e Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Tue, 26 Mar 2024 15:11:51 +0400 Subject: [PATCH] feat: SSO for orgs (#13794) * WIP Adds the capability to add SSO setup in orgs if you are an Admin or an Owner of the org * remove sso setup from teams * allows account creation on first login * auto-add on login and dsync fixes * allow SP & idP initiated login for SAML * revert change to checkIfUserShouldbelongToOrg * fixes SP initiated Login for first time * removed stale comment * code improvement * -- * minor fixes * more fix * add description for non-admin sso settings page --- .../{teams/[id] => organizations}/sso.tsx | 4 +- apps/web/public/static/locales/en/common.json | 2 + .../features/auth/lib/next-auth-options.ts | 57 +++++++++++---- .../ee/dsync/lib/handleGroupEvents.ts | 11 +-- .../features/ee/dsync/lib/handleUserEvents.ts | 10 ++- .../lib/users/createUsersAndConnectToOrg.ts | 36 ++++++---- packages/features/ee/sso/lib/sso.ts | 70 ++++++++++++++++--- .../features/ee/sso/page/orgs-sso-view.tsx | 56 +++++++++++++++ .../settings/layouts/SettingsLayout.tsx | 12 ++-- 9 files changed, 206 insertions(+), 52 deletions(-) rename apps/web/pages/settings/{teams/[id] => organizations}/sso.tsx (59%) create mode 100644 packages/features/ee/sso/page/orgs-sso-view.tsx diff --git a/apps/web/pages/settings/teams/[id]/sso.tsx b/apps/web/pages/settings/organizations/sso.tsx similarity index 59% rename from apps/web/pages/settings/teams/[id]/sso.tsx rename to apps/web/pages/settings/organizations/sso.tsx index d2022162a1..5b3b506c42 100644 --- a/apps/web/pages/settings/teams/[id]/sso.tsx +++ b/apps/web/pages/settings/organizations/sso.tsx @@ -1,9 +1,9 @@ -import TeamSSOView from "@calcom/features/ee/sso/page/teams-sso-view"; +import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view"; import type { CalPageWrapper } from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper"; -const Page = TeamSSOView as CalPageWrapper; +const Page = OrgSSOView as CalPageWrapper; Page.PageWrapper = PageWrapper; export default Page; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1c71880fe4..77570ac465 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1626,6 +1626,7 @@ "payment_app_disabled": "An admin has disabled a payment app", "edit_event_type": "Edit event type", "only_admin_can_see_members_of_org": "This Organization is private, and only the organization's admin or owner can view its members.", + "only_admin_can_manage_sso_org": "Only the organization's admin or owner can manage SSO settings", "collective_scheduling": "Collective Scheduling", "make_it_easy_to_book": "Make it easy to book your team when everyone is available.", "find_the_best_person": "Find the best person available and cycle through your team.", @@ -1759,6 +1760,7 @@ "configure": "Configure", "sso_configuration": "Single Sign-On", "sso_configuration_description": "Configure SAML/OIDC SSO and allow team members to login using an Identity Provider", + "sso_configuration_description_orgs": "Configure SAML/OIDC SSO and allow organization members to login using an Identity Provider", "sso_oidc_heading": "SSO with OIDC", "sso_oidc_description": "Configure OIDC SSO with Identity Provider of your choice.", "sso_oidc_configuration_title": "OIDC Configuration", diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 0f755257c4..d38b3d67d1 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -9,10 +9,12 @@ import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; import checkLicense from "@calcom/features/ee/common/server/checkLicense"; +import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg"; import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider"; import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto"; import { defaultCookies } from "@calcom/lib/default-cookies"; @@ -42,7 +44,21 @@ const ORGANIZATIONS_AUTOLINK = process.env.ORGANIZATIONS_AUTOLINK === "1" || process.env.ORGANIZATIONS_AUTOLINK === "true"; const usernameSlug = (username: string) => `${slugify(username)}-${randomString(6).toLowerCase()}`; - +const getDomainFromEmail = (email: string): string => email.split("@")[1]; +const getVerifiedOrganizationByAutoAcceptEmailDomain = async (domain: string) => { + const existingOrg = await prisma.team.findFirst({ + where: { + organizationSettings: { + isOrganizationVerified: true, + orgAutoAcceptEmail: domain, + }, + }, + select: { + id: true, + }, + }); + return existingOrg?.id; +}; const loginWithTotp = async (email: string) => `/auth/login?totp=${await (await import("./signJwt")).default({ email })}`; @@ -269,9 +285,7 @@ if (isSAMLLoginEnabled) { const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email: profile.email || "", }); - if (!user) { - throw new Error(ErrorCode.UserNotFound); - } + if (!user) throw new Error(ErrorCode.UserNotFound); const [userProfile] = user.allProfiles; return { @@ -325,7 +339,6 @@ if (isSAMLLoginEnabled) { if (!access_token) { return null; } - // Fetch user info const userInfo = await oauthController.userInfo(access_token); @@ -334,13 +347,30 @@ if (isSAMLLoginEnabled) { } const { id, firstName, lastName, email } = userInfo; - const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email }); + let user = !email + ? undefined + : await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email }); if (!user) { - throw new Error(ErrorCode.UserNotFound); + const hostedCal = Boolean(HOSTED_CAL_FEATURES); + if (hostedCal && email) { + const domain = getDomainFromEmail(email); + const organizationId = await getVerifiedOrganizationByAutoAcceptEmailDomain(domain); + if (organizationId) { + const createUsersAndConnectToOrgProps = { + emailsToCreate: [email], + organizationId, + identityProvider: IdentityProvider.SAML, + identityProviderId: email, + }; + await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({ + email: email, + }); + } + } + if (!user) throw new Error(ErrorCode.UserNotFound); } - - const [profile] = user.allProfiles; - + const [userProfile] = user?.allProfiles; return { id: id as unknown as number, firstName, @@ -348,7 +378,7 @@ if (isSAMLLoginEnabled) { email, name: `${firstName} ${lastName}`.trim(), email_verified: true, - profile, + profile: userProfile, }; }, }) @@ -430,7 +460,6 @@ export const AUTH_OPTIONS: AuthOptions = { account, }) { log.debug("callbacks:jwt", safeStringify({ token, user, account, trigger, session })); - // The data available in 'session' depends on what data was supplied in update method call of session if (trigger === "update") { return { @@ -619,7 +648,6 @@ export const AUTH_OPTIONS: AuthOptions = { if (account?.provider === "email") { return true; } - // In this case we've already verified the credentials in the authorize // callback so we can sign the user in. // Only if provider is not saml-idp @@ -632,7 +660,6 @@ export const AUTH_OPTIONS: AuthOptions = { return false; } } - if (!user.email) { return false; } @@ -640,7 +667,6 @@ export const AUTH_OPTIONS: AuthOptions = { if (!user.name) { return false; } - if (account?.provider) { const idP: IdentityProvider = mapIdentityProvider(account.provider); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -736,6 +762,7 @@ export const AUTH_OPTIONS: AuthOptions = { // If there's no existing user for this identity provider and id, create // a new account. If an account already exists with the incoming email // address return an error for now. + const existingUserWithEmail = await prisma.user.findFirst({ where: { email: { diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index be4633ac13..586b2c2f8b 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -4,7 +4,7 @@ import jackson from "@calcom/features/ee/sso/lib/jackson"; import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { getTeamOrThrow, @@ -96,10 +96,13 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb // For each team linked to the dsync group name provision members for (const group of groupNames) { if (newUserEmails.length) { - const newUsers = await createUsersAndConnectToOrg({ + const createUsersAndConnectToOrgProps = { emailsToCreate: newUserEmails, - org, - }); + organizationId: org.id, + identityProvider: IdentityProvider.CAL, + identityProviderId: null, + }; + const newUsers = await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); await prisma.membership.createMany({ data: newUsers.map((user) => ({ userId: user.id, diff --git a/packages/features/ee/dsync/lib/handleUserEvents.ts b/packages/features/ee/dsync/lib/handleUserEvents.ts index 90ae558320..2f16e24b3a 100644 --- a/packages/features/ee/dsync/lib/handleUserEvents.ts +++ b/packages/features/ee/dsync/lib/handleUserEvents.ts @@ -3,6 +3,7 @@ import type { DirectorySyncEvent, User } from "@boxyhq/saml-jackson"; import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; +import { IdentityProvider } from "@calcom/prisma/enums"; import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; @@ -64,10 +65,13 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe } // If user is not in DB, create user and add to the org } else { - await createUsersAndConnectToOrg({ + const createUsersAndConnectToOrgProps = { emailsToCreate: [userEmail], - org, - }); + organizationId: org.id, + identityProvider: IdentityProvider.CAL, + identityProviderId: null, + }; + await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); await sendSignupToOrganizationEmail({ usernameOrEmail: userEmail, diff --git a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts index 2df57373d8..5f446d6920 100644 --- a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts +++ b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts @@ -1,32 +1,43 @@ import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; +import type { IdentityProvider } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums"; -import type { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import dSyncUserSelect from "./dSyncUserSelect"; -const createUsersAndConnectToOrg = async ({ - emailsToCreate, - org, -}: { +type createUsersAndConnectToOrgPropsType = { emailsToCreate: string[]; - org: Awaited>; -}) => { - const orgId = org.id; + organizationId: number; + identityProvider: IdentityProvider; + identityProviderId: string | null; +}; +const createUsersAndConnectToOrg = async ( + createUsersAndConnectToOrgProps: createUsersAndConnectToOrgPropsType +) => { + const { emailsToCreate, organizationId, identityProvider, identityProviderId } = + createUsersAndConnectToOrgProps; // As of Mar 2024 Prisma createMany does not support nested creates and returning created records await prisma.user.createMany({ data: emailsToCreate.map((email) => { const [emailUser, emailDomain] = email.split("@"); const username = slugify(`${emailUser}-${emailDomain.split(".")[0]}`); + const name = username + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); return { username, email, + name, // Assume verified since coming from directory verified: true, - invitedTo: orgId, - organizationId: orgId, + emailVerified: new Date(), + invitedTo: organizationId, + organizationId, + identityProvider, + identityProviderId, }; }), }); @@ -42,8 +53,9 @@ const createUsersAndConnectToOrg = async ({ await prisma.membership.createMany({ data: users.map((user) => ({ + accepted: true, userId: user.id, - teamId: orgId, + teamId: organizationId, role: MembershipRole.MEMBER, })), }); @@ -54,7 +66,7 @@ const createUsersAndConnectToOrg = async ({ userId: user.id, // The username is already set when creating the user username: user.username!, - organizationId: orgId, + organizationId, })), }); diff --git a/packages/features/ee/sso/lib/sso.ts b/packages/features/ee/sso/lib/sso.ts index e7bdc32971..18b8839b3c 100644 --- a/packages/features/ee/sso/lib/sso.ts +++ b/packages/features/ee/sso/lib/sso.ts @@ -1,13 +1,14 @@ +import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg"; +import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import type { PrismaClient } from "@calcom/prisma"; +import { IdentityProvider } from "@calcom/prisma/enums"; import { TRPCError } from "@calcom/trpc/server"; import jackson from "./jackson"; import { tenantPrefix, samlProductID } from "./saml"; -export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { - const { connectionController } = await jackson(); - - const memberships = await prisma.membership.findMany({ +const getAllAcceptedMemberships = async ({ prisma, email }: { prisma: PrismaClient; email: string }) => { + return await prisma.membership.findMany({ select: { teamId: true, }, @@ -18,12 +19,65 @@ export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { }, }, }); +}; + +const getVerifiedOrganizationByAutoAcceptEmailDomain = async ({ + prisma, + domain, +}: { + prisma: PrismaClient; + domain: string; +}) => { + return await prisma.team.findFirst({ + where: { + organizationSettings: { + isOrganizationVerified: true, + orgAutoAcceptEmail: domain, + }, + }, + select: { + id: true, + }, + }); +}; + +export const ssoTenantProduct = async (prisma: PrismaClient, email: string) => { + const { connectionController } = await jackson(); + + let memberships = await getAllAcceptedMemberships({ prisma, email }); if (!memberships || memberships.length === 0) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "no_account_exists", - }); + if (!HOSTED_CAL_FEATURES) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "no_account_exists", + }); + + const domain = email.split("@")[1]; + const organization = await getVerifiedOrganizationByAutoAcceptEmailDomain({ prisma, domain }); + + if (!organization) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "no_account_exists", + }); + + const organizationId = organization.id; + const createUsersAndConnectToOrgProps = { + emailsToCreate: [email], + organizationId, + identityProvider: IdentityProvider.SAML, + identityProviderId: email, + }; + + await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps); + memberships = await getAllAcceptedMemberships({ prisma, email }); + + if (!memberships || memberships.length === 0) + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "no_account_exists", + }); } // Check SSO connections for each team user is a member of diff --git a/packages/features/ee/sso/page/orgs-sso-view.tsx b/packages/features/ee/sso/page/orgs-sso-view.tsx new file mode 100644 index 0000000000..ea27d90ff8 --- /dev/null +++ b/packages/features/ee/sso/page/orgs-sso-view.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { AppSkeletonLoader as SkeletonLoader, Meta } from "@calcom/ui"; + +import { getLayout } from "../../../settings/layouts/SettingsLayout"; +import SSOConfiguration from "../components/SSOConfiguration"; + +const SAMLSSO = () => { + const { t } = useLocale(); + const router = useRouter(); + + const { + data: currentOrg, + isPending, + error, + } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {}); + + useEffect( + function refactorMeWithoutEffect() { + if (error) { + router.push("/settings"); + } + }, + [error] + ); + + if (isPending) + ; + if (!currentOrg) { + return null; + } + + const isAdminOrOwner = + currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN; + + return isAdminOrOwner ? ( +
+ + +
+ ) : ( +
+ {t("only_admin_can_manage_sso_org")} +
+ ); +}; + +SAMLSSO.getLayout = getLayout; + +export default SAMLSSO; diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index e981d257c4..099bb464ea 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -105,6 +105,10 @@ const tabs: VerticalTabItemProps[] = [ href: "/settings/organizations/billing", }, { name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" }, + { + name: "SSO", + href: "/settings/organizations/sso", + }, { name: "directory_sync", href: "/settings/organizations/dsync", @@ -447,14 +451,6 @@ const SettingsSidebarContainer = ({ textClassNames="px-3 text-emphasis font-medium text-sm" disableChevron /> - {HOSTED_CAL_FEATURES && ( - - )} ) : null}