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:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user