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
This commit is contained in:
Syed Ali Shahbaz
2024-03-26 11:11:51 +00:00
committed by GitHub
parent 452ea6a993
commit fd91c5da79
9 changed files with 206 additions and 52 deletions
@@ -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;
@@ -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",
+42 -15
View File
@@ -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: {
@@ -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,
@@ -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,
@@ -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<ReturnType<typeof getTeamOrThrow>>;
}) => {
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,
})),
});
+62 -8
View File
@@ -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
@@ -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)
<SkeletonLoader title={t("sso_saml_heading")} description={t("sso_configuration_description_orgs")} />;
if (!currentOrg) {
return null;
}
const isAdminOrOwner =
currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN;
return isAdminOrOwner ? (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta title={t("sso_configuration")} description={t("sso_configuration_description_orgs")} />
<SSOConfiguration teamId={currentOrg?.id} />
</div>
) : (
<div className="py-5">
<span className="text-default text-sm">{t("only_admin_can_manage_sso_org")}</span>
</div>
);
};
SAMLSSO.getLayout = getLayout;
export default SAMLSSO;
@@ -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 && (
<VerticalTabItem
name={t("saml_config")}
href={`/settings/teams/${team.id}/sso`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
</>
) : null}
</>