Reintroduce SAML SSO (#4938)

* wip reintroduce SAML SSO

* Fix the imports

* wip

* Some tweaks

* Fix the type

* Reduce the textarea height

* Cleanup

* Fix the access issues

* Make the SAML SSO active on the sidebar

* Add SP's instructions

* Remove the console.log

* Add the condition to check SAML SSO is enabled

* Replace SAML SSO with Single Sign-On

* Update to SAML feature

* Upgrade the @boxyhq/saml-jackson

* Fix the SAML part and other cleanup

* Tweaks to SAML SSO setup

* Fix the type

* Fix the import path

* Remove samlLoginUrl

* Import fixes

* Simplifies endpoints

Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Kiran K 2022-10-19 02:04:32 +05:30 committed by GitHub
parent 41689ecc92
commit 759a89bb0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 990 additions and 1017 deletions

View File

@ -1,40 +0,0 @@
import jackson, { IAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
import { BASE_URL } from "@lib/config/constants";
import { samlDatabaseUrl } from "@lib/saml";
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
const opts: JacksonOption = {
externalUrl: BASE_URL,
samlPath: "/api/auth/saml/callback",
db: {
engine: "sql",
type: "postgres",
url: samlDatabaseUrl,
encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY,
},
samlAudience: "https://saml.cal.com",
};
let apiController: IAPIController;
let oauthController: IOAuthController;
const g = global as any;
export default async function init() {
if (!g.apiController || !g.oauthController) {
const ret = await jackson(opts);
apiController = ret.apiController;
oauthController = ret.oauthController;
g.apiController = apiController;
g.oauthController = oauthController;
} else {
apiController = g.apiController;
oauthController = g.oauthController;
}
return {
apiController,
oauthController,
};
}

View File

@ -1,59 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { TRPCError } from "@calcom/trpc/server";
import { BASE_URL } from "@lib/config/constants";
export const samlDatabaseUrl = process.env.SAML_DATABASE_URL || "";
export const samlLoginUrl = BASE_URL;
export const isSAMLLoginEnabled = samlDatabaseUrl.length > 0;
export const samlTenantID = "Cal.com";
export const samlProductID = "Cal.com";
const samlAdmins = (process.env.SAML_ADMINS || "").split(",");
export const hostedCal = BASE_URL === "https://app.cal.com";
export const tenantPrefix = "team-";
export const isSAMLAdmin = (email: string) => {
for (const admin of samlAdmins) {
if (admin.toLowerCase() === email.toLowerCase() && admin.toUpperCase() === email.toUpperCase()) {
return true;
}
}
return false;
};
export const samlTenantProduct = async (prisma: PrismaClient, email: string) => {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
id: true,
invitedTo: true,
},
});
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Unauthorized Request",
});
}
if (!user.invitedTo) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Could not find a SAML Identity Provider for your email. Please contact your admin to ensure you have been given access to Cal",
});
}
return {
tenant: tenantPrefix + user.invitedTo,
product: samlProductID,
};
};

View File

@ -23,7 +23,7 @@
"yarn": ">=1.19.0 < 2.0.0"
},
"dependencies": {
"@boxyhq/saml-jackson": "0.3.6",
"@boxyhq/saml-jackson": "1.3.1",
"@calcom/app-store": "*",
"@calcom/app-store-cli": "*",
"@calcom/core": "*",

View File

@ -12,6 +12,7 @@ import path from "path";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies";
@ -22,7 +23,6 @@ import prisma from "@calcom/prisma";
import { ErrorCode, verifyPassword } from "@lib/auth";
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
import { randomString } from "@lib/random";
import { hostedCal, isSAMLLoginEnabled, samlLoginUrl } from "@lib/saml";
import slugify from "@lib/slugify";
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
@ -135,7 +135,7 @@ if (isSAMLLoginEnabled) {
version: "2.0",
checks: ["pkce", "state"],
authorization: {
url: `${samlLoginUrl}/api/auth/saml/authorize`,
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
params: {
scope: "",
response_type: "code",
@ -143,10 +143,10 @@ if (isSAMLLoginEnabled) {
},
},
token: {
url: `${samlLoginUrl}/api/auth/saml/token`,
url: `${WEBAPP_URL}/api/auth/saml/token`,
params: { grant_type: "authorization_code" },
},
userinfo: `${samlLoginUrl}/api/auth/saml/userinfo`,
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
profile: (profile) => {
return {
id: profile.id || "",

View File

@ -1,25 +1,24 @@
import { OAuthReqBody } from "@boxyhq/saml-jackson";
import { OAuthReq } from "@boxyhq/saml-jackson";
import { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import jackson from "@lib/jackson";
import { HttpError } from "@lib/core/http/error";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "GET") {
throw new Error("Method not allowed");
}
const { oauthController } = await jackson();
const { oauthController } = await jackson();
const { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReqBody);
res.redirect(302, redirect_url);
} catch (err: unknown) {
if (err instanceof HttpError) {
console.error("authorize error:", err);
const { message, statusCode = 500 } = err;
return res.status(statusCode).send(message);
}
return res.status(500).send("Unknown error");
if (req.method !== "GET") {
return res.status(400).send("Method not allowed");
}
try {
const { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReq);
return res.redirect(302, redirect_url as string);
} catch (err) {
const { message, statusCode = 500 } = err as HttpError;
return res.status(statusCode).send(message);
}
}

View File

@ -1,25 +1,14 @@
import { NextApiRequest, NextApiResponse } from "next";
import { HttpError } from "@calcom/lib/http-error";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import jackson from "@lib/jackson";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new Error("Method not allowed");
}
const { oauthController } = await jackson();
const { redirect_url } = await oauthController.samlResponse(req.body);
res.redirect(302, redirect_url);
} catch (err: unknown) {
if (err instanceof HttpError) {
console.error("callback error:", err);
const { message, statusCode = 500 } = err;
return res.status(statusCode).send(message);
}
return res.status(500).send("Unknown error");
}
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const { oauthController } = await jackson();
const { redirect_url } = await oauthController.samlResponse(req.body);
if (redirect_url) return res.redirect(302, redirect_url);
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,21 +1,13 @@
import { NextApiRequest, NextApiResponse } from "next";
import { NextApiRequest } from "next";
import jackson from "@lib/jackson";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new Error("Method not allowed");
}
const { oauthController } = await jackson();
const result = await oauthController.token(req.body);
res.json(result);
} catch (err: any) {
console.error("token error:", err);
const { message, statusCode = 500 } = err;
res.status(statusCode).send(message);
}
async function postHandler(req: NextApiRequest) {
const { oauthController } = await jackson();
return await oauthController.token(req.body);
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,53 +1,34 @@
import { NextApiRequest, NextApiResponse } from "next";
import { NextApiRequest } from "next";
import z from "zod";
import jackson from "@lib/jackson";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
const extractAuthToken = (req: NextApiRequest) => {
const authHeader = req.headers["authorization"];
const parts = (authHeader || "").split(" ");
if (parts.length > 1) {
return parts[1];
}
if (parts.length > 1) return parts[1];
return null;
// check for query param
let arr: string[] = [];
const { access_token } = requestQuery.parse(req.query);
arr = arr.concat(access_token);
if (arr[0].length > 0) return arr[0];
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
};
const requestQuery = z.object({
access_token: z.string(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "GET") {
throw new Error("Method not allowed");
}
const { oauthController } = await jackson();
let token: string | null = extractAuthToken(req);
// check for query param
if (!token) {
let arr: string[] = [];
const { access_token } = requestQuery.parse(req.query);
arr = arr.concat(access_token);
if (arr[0].length > 0) {
token = arr[0];
}
}
if (!token) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const profile = await oauthController.userInfo(token);
res.json(profile);
} catch (err: any) {
console.error("userinfo error:", err);
const { message, statusCode = 500 } = err;
res.status(statusCode).json({ message });
}
async function getHandler(req: NextApiRequest) {
const { oauthController } = await jackson();
const token = extractAuthToken(req);
return await oauthController.userInfo(token);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

View File

@ -7,6 +7,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { FaGoogle } from "react-icons/fa";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
@ -18,7 +19,6 @@ import SAMLLogin from "@calcom/ui/v2/modules/auth/SAMLLogin";
import { ErrorCode, getSession } from "@lib/auth";
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@lib/saml";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import AddToHomescreen from "@components/AddToHomescreen";

View File

@ -5,12 +5,18 @@ import { useEffect } from "react";
import { getPremiumPlanPrice } from "@calcom/app-store/stripepayment/lib/utils";
import stripe from "@calcom/features/ee/payments/server/stripe";
import {
hostedCal,
isSAMLLoginEnabled,
samlProductID,
samlTenantID,
samlTenantProduct,
} from "@calcom/features/ee/sso/lib/saml";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import prisma from "@calcom/prisma";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID, samlTenantProduct } from "@lib/saml";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { ssrInit } from "@server/lib/ssr";

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/sso/page/user-sso-view";

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/sso/page/teams-sso-view";

View File

@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert } from "@calcom/ui/Alert";
@ -13,7 +14,6 @@ import { HeadSeo } from "@calcom/web/components/seo/head-seo";
import { asStringOrNull } from "@calcom/web/lib/asStringOrNull";
import { WEBAPP_URL } from "@calcom/web/lib/config/constants";
import prisma from "@calcom/web/lib/prisma";
import { isSAMLLoginEnabled } from "@calcom/web/lib/saml";
import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/web/server/lib/constants";
import { ssrInit } from "@calcom/web/server/lib/ssr";

View File

@ -10,7 +10,7 @@ test.describe("SAML tests", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML is enabled");
// Try to go Security page
await page.goto("/settings/security");
await page.goto("/settings/security/sso");
// It should redirect you to the event-types page
// await page.waitForSelector("[data-testid=saml_config]");
});

View File

@ -746,22 +746,11 @@
"user_away_description": "The person you are trying to book has set themselves to away, and therefore is not accepting new bookings.",
"meet_people_with_the_same_tokens": "Meet people with the same tokens",
"only_book_people_and_allow": "Only book and allow bookings from people who share the same tokens, DAOs, or NFTs.",
"saml_config_deleted_successfully": "SAML configuration deleted successfully",
"account_created_with_identity_provider": "Your account was created using an Identity Provider.",
"account_managed_by_identity_provider": "Your account is managed by {{provider}}",
"account_managed_by_identity_provider_description": "To change your email, password, enable two-factor authentication and more, please visit your {{provider}} account settings.",
"signin_with_google": "Sign in with Google",
"signin_with_saml": "Sign in with SAML",
"saml_configuration": "SAML configuration",
"delete_saml_configuration": "Delete SAML configuration",
"delete_saml_configuration_confirmation_message": "Are you sure you want to delete the SAML configuration? Your team members who use SAML login will no longer be able to access Cal.com.",
"confirm_delete_saml_configuration": "Yes, delete SAML configuration",
"saml_not_configured_yet": "SAML not configured yet",
"saml_configuration_description": "Please paste the SAML metadata from your Identity Provider in the textbox below to update your SAML configuration.",
"saml_configuration_placeholder": "Please paste the SAML metadata from your Identity Provider here",
"saml_configuration_update_failed": "SAML configuration update failed",
"saml_configuration_delete_failed": "SAML configuration delete failed",
"saml_email_required": "Please enter an email so we can find your SAML Identity Provider",
"you_will_need_to_generate": "You will need to generate an access token from your old scheduling tool.",
"import": "Import",
"import_from": "Import from",
@ -1241,7 +1230,6 @@
"theme_dark": "Dark",
"theme_system": "System default",
"add_a_team": "Add a team",
"saml_config": "SAML Config",
"add_webhook_description": "Receive meeting data in real-time when something happens in Cal.com",
"triggers_when": "Triggers when",
"test_webhook": "Please ping test before creating.",
@ -1315,5 +1303,25 @@
"no_available_slots": "No Available slots",
"time_available": "Time available",
"install_new_calendar_app": "Install new calendar app",
"make_phone_number_required": "Make phone number required for booking event"
"make_phone_number_required": "Make phone number required for booking event",
"dont_have_permission": "You don't have permission to access this resource.",
"saml_config": "Single Sign-On",
"saml_description": "Allow team members to login using an Identity Provider",
"saml_config_deleted_successfully": "SAML configuration deleted successfully",
"saml_config_updated_successfully": "SAML configuration updated successfully",
"saml_configuration": "SAML configuration",
"delete_saml_configuration": "Delete SAML configuration",
"delete_saml_configuration_confirmation_message": "Are you sure you want to delete the SAML configuration? Your team members who use SAML login will no longer be able to access Cal.com.",
"confirm_delete_saml_configuration": "Yes, delete SAML configuration",
"saml_not_configured_yet": "SAML not configured yet",
"saml_configuration_description": "Please paste the SAML metadata from your Identity Provider in the textbox below to update your SAML configuration.",
"saml_configuration_placeholder": "Please paste the SAML metadata from your Identity Provider here",
"saml_email_required": "Please enter an email so we can find your SAML Identity Provider",
"saml_sp_title": "Service Provider Details",
"saml_sp_description": "Your Identity Provider (IdP) will ask you for the following details to complete the SAML application configuration.",
"saml_sp_acs_url": "ACS URL",
"saml_sp_entity_id": "SP Entity ID",
"saml_sp_acs_url_copied": "ACS URL copied!",
"saml_sp_entity_id_copied": "SP Entity ID copied!",
"saml_btn_configure": "Configure"
}

View File

@ -1,164 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/Alert";
import Badge from "@calcom/ui/Badge";
import Button from "@calcom/ui/Button";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { TextArea } from "@calcom/ui/form/fields";
import LicenseRequired from "./LicenseRequired";
export default function SAMLConfiguration({
teamsView,
teamId,
}: {
teamsView: boolean;
teamId: null | undefined | number;
}) {
const [isSAMLLoginEnabled, setIsSAMLLoginEnabled] = useState(false);
const [samlConfig, setSAMLConfig] = useState<string | null>(null);
const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]);
const telemetry = useTelemetry();
useEffect(() => {
const data = query.data;
setIsSAMLLoginEnabled(data?.isSAMLLoginEnabled ?? false);
setSAMLConfig(data?.provider ?? null);
}, [query.data]);
const mutation = trpc.useMutation("viewer.updateSAMLConfig", {
onSuccess: (data: { provider: string | undefined }) => {
showToast(t("saml_config_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
setSAMLConfig(data?.provider ?? null);
samlConfigRef.current.value = "";
},
onError: () => {
setHasErrors(true);
setErrorMessage(t("saml_configuration_update_failed"));
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
},
});
const deleteMutation = trpc.useMutation("viewer.deleteSAMLConfig", {
onSuccess: () => {
showToast(t("saml_config_deleted_successfully"), "success");
setHasErrors(false); // dismiss any open errors
setSAMLConfig(null);
samlConfigRef.current.value = "";
},
onError: () => {
setHasErrors(true);
setErrorMessage(t("saml_configuration_delete_failed"));
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
},
});
const samlConfigRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
async function updateSAMLConfigHandler(event: React.FormEvent<HTMLElement>) {
event.preventDefault();
const rawMetadata = samlConfigRef.current.value;
// track Google logins. Without personal data/payload
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
mutation.mutate({
encodedRawMetadata: Buffer.from(rawMetadata).toString("base64"),
teamId,
});
}
async function deleteSAMLConfigHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
event.preventDefault();
deleteMutation.mutate({
teamId,
});
}
const { t } = useLocale();
return (
<>
{isSAMLLoginEnabled ? (
<LicenseRequired>
<hr className="mt-8" />
<div className="mt-6">
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">
{t("saml_configuration")}
<Badge className="ml-2 text-xs" variant={samlConfig ? "success" : "gray"}>
{samlConfig ? t("enabled") : t("disabled")}
</Badge>
{samlConfig ? (
<>
<Badge className="ml-2 text-xs" variant="success">
{samlConfig ? samlConfig : ""}
</Badge>
</>
) : null}
</h2>
</div>
{samlConfig ? (
<div className="mt-2 flex">
<Dialog>
<DialogTrigger asChild>
<Button
color="warn"
type="button"
onClick={(e) => {
e.stopPropagation();
}}>
{t("delete_saml_configuration")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("delete_saml_configuration")}
confirmBtnText={t("confirm_delete_saml_configuration")}
cancelBtnText={t("cancel")}
onConfirm={deleteSAMLConfigHandler}>
{t("delete_saml_configuration_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
) : (
<p className="mt-1 text-sm text-gray-500">{!samlConfig ? t("saml_not_configured_yet") : ""}</p>
)}
<p className="mt-1 text-sm text-gray-500">{t("saml_configuration_description")}</p>
<form className="mt-3 divide-y divide-gray-200 lg:col-span-9" onSubmit={updateSAMLConfigHandler}>
{hasErrors && <Alert severity="error" title={errorMessage} />}
<TextArea
data-testid="saml_config"
ref={samlConfigRef}
name="saml_config"
id="saml_config"
required={true}
rows={10}
placeholder={t("saml_configuration_placeholder")}
/>
<div className="flex justify-end py-8">
<Button type="submit">{t("save")}</Button>
</div>
<hr className="mt-4" />
</form>
</LicenseRequired>
) : null}
</>
);
}

View File

@ -0,0 +1,88 @@
import { Controller, useForm } from "react-hook-form";
import LicenseRequired from "@calcom/ee/common/components/v2/LicenseRequired";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { DialogFooter } from "@calcom/ui/Dialog";
import { showToast } from "@calcom/ui/v2";
import Button from "@calcom/ui/v2/core/Button";
import { Form, TextArea } from "@calcom/ui/v2/core/form/fields";
interface TeamSSOValues {
metadata: string;
}
export default function ConfigDialogForm({
teamId,
handleClose,
}: {
teamId: number | null;
handleClose: () => void;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const telemetry = useTelemetry();
const form = useForm<TeamSSOValues>();
const mutation = trpc.useMutation("viewer.saml.update", {
async onSuccess() {
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
await utils.invalidateQueries(["viewer.saml.get"]);
showToast(t("saml_config_updated_successfully"), "success");
handleClose();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<LicenseRequired>
<Form
form={form}
handleSubmit={(values) => {
mutation.mutate({
teamId,
encodedRawMetadata: Buffer.from(values.metadata).toString("base64"),
});
}}>
<div className="mb-10 mt-1">
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">
{t("saml_configuration")}
</h2>
<p className="mt-1 mb-5 text-sm text-gray-500">{t("saml_configuration_description")}</p>
</div>
<Controller
control={form.control}
name="metadata"
render={({ field: { value } }) => (
<div>
<TextArea
data-testid="saml_config"
name="metadata"
value={value}
className="h-40"
required={true}
placeholder={t("saml_configuration_placeholder")}
onChange={(e) => {
form.setValue("metadata", e?.target.value);
}}
/>
</div>
)}
/>
<DialogFooter>
<Button type="button" color="secondary" onClick={handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
</LicenseRequired>
);
}

View File

@ -0,0 +1,175 @@
import { useState } from "react";
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
import ConfigDialogForm from "@calcom/features/ee/sso/components/ConfigDialogForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Alert } from "@calcom/ui/Alert";
import { ClipboardCopyIcon } from "@calcom/ui/Icon";
import { Button, showToast, Label } from "@calcom/ui/v2";
import Badge from "@calcom/ui/v2/core/Badge";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
import { Dialog, DialogTrigger, DialogContent } from "@calcom/ui/v2/core/Dialog";
import Meta from "@calcom/ui/v2/core/Meta";
import SkeletonLoader from "@calcom/ui/v2/core/apps/SkeletonLoader";
export default function SAMLConfiguration({ teamId }: { teamId: number | null }) {
const { t } = useLocale();
const utils = trpc.useContext();
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [configModal, setConfigModal] = useState(false);
const { data: connection, isLoading } = trpc.useQuery(["viewer.saml.get", { teamId }], {
onError: (err) => {
setHasError(true);
setErrorMessage(err.message);
},
onSuccess: () => {
setHasError(false);
setErrorMessage("");
},
});
const mutation = trpc.useMutation("viewer.saml.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.saml.get"]);
showToast(t("saml_config_deleted_successfully"), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const deleteConnection = () => {
mutation.mutate({
teamId,
});
};
if (isLoading) {
return <SkeletonLoader />;
}
if (hasError) {
return (
<>
<Meta title={t("saml_config")} description={t("saml_description")} />
<Alert severity="warning" message={t(errorMessage)} className="mb-4 " />
</>
);
}
return (
<>
<Meta title={t("saml_config")} description={t("saml_description")} />
<LicenseRequired>
<div className="flex flex-col justify-between md:flex-row">
<div className="mb-3">
{connection && connection.provider ? (
<Badge variant="green" bold>
SAML SSO enabled via {connection.provider}
</Badge>
) : (
<Badge variant="gray" bold>
{t("saml_not_configured_yet")}
</Badge>
)}
</div>
<div>
<Button
color="secondary"
StartIcon={Icon.FiDatabase}
onClick={() => {
setConfigModal(true);
}}>
{t("saml_btn_configure")}
</Button>
</div>
</div>
{/* Service Provider Details */}
{connection && connection.provider && (
<>
<hr className="border-1 my-8 border-gray-200" />
<div className="mb-3 text-base font-semibold">{t("saml_sp_title")}</div>
<p className="mt-3 text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
{t("saml_sp_description")}
</p>
<div className="mt-5 flex flex-col">
<div className="flex">
<Label>{t("saml_sp_acs_url")}</Label>
</div>
<div className="flex">
<code className="mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 font-mono text-gray-800">
{connection.acsUrl}
</code>
<Button
onClick={() => {
navigator.clipboard.writeText(connection.acsUrl);
showToast(t("saml_sp_acs_url_copied"), "success");
}}
type="button"
className="px-4 text-base">
<ClipboardCopyIcon className="h-5 w-5 text-neutral-100" />
{t("copy")}
</Button>
</div>
</div>
<div className="mt-5 flex flex-col">
<div className="flex">
<Label>{t("saml_sp_entity_id")}</Label>
</div>
<div className="flex">
<code className="mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 font-mono text-gray-800">
{connection.entityId}
</code>
<Button
onClick={() => {
navigator.clipboard.writeText(connection.entityId);
showToast(t("saml_sp_entity_id_copied"), "success");
}}
type="button"
className="px-4 text-base">
<ClipboardCopyIcon className="h-5 w-5 text-neutral-100" />
{t("copy")}
</Button>
</div>
</div>
</>
)}
{/* Danger Zone and Delete Confirmation */}
{connection && connection.provider && (
<>
<hr className="border-1 my-8 border-gray-200" />
<div className="mb-3 text-base font-semibold">{t("danger_zone")}</div>
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Icon.FiTrash2}>
{t("delete_saml_configuration")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("delete_saml_configuration")}
confirmBtnText={t("confirm_delete_saml_configuration")}
onConfirm={deleteConnection}>
{t("delete_saml_configuration_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</>
)}
{/* Add/Update SAML Connection */}
<Dialog open={configModal} onOpenChange={setConfigModal}>
<DialogContent type="creation" useOwnActionButtons>
<ConfigDialogForm handleClose={() => setConfigModal(false)} teamId={teamId} />
</DialogContent>
</Dialog>
</LicenseRequired>
</>
);
}

View File

@ -0,0 +1,53 @@
import jackson, {
IConnectionAPIController,
IOAuthController,
JacksonOption,
ISPSAMLConfig,
} from "@boxyhq/saml-jackson";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { samlDatabaseUrl, samlAudience, samlPath } from "./saml";
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
const opts: JacksonOption = {
externalUrl: WEBAPP_URL,
samlPath,
samlAudience,
db: {
engine: "sql",
type: "postgres",
url: samlDatabaseUrl,
encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY,
},
};
let connectionController: IConnectionAPIController;
let oauthController: IOAuthController;
let samlSPConfig: ISPSAMLConfig;
const g = global as any;
export default async function init() {
if (!g.connectionController || !g.oauthController) {
const ret = await jackson(opts);
connectionController = ret.connectionAPIController;
oauthController = ret.oauthController;
samlSPConfig = ret.spConfig;
g.connectionController = connectionController;
g.oauthController = oauthController;
g.samlSPConfig = samlSPConfig;
} else {
connectionController = g.connectionController;
oauthController = g.oauthController;
samlSPConfig = g.samlSPConfig;
}
return {
connectionController,
oauthController,
samlSPConfig,
};
}

View File

@ -1,20 +1,21 @@
import { PrismaClient } from "@prisma/client";
import { PrismaClient, UserPlan } from "@prisma/client";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { TRPCError } from "@calcom/trpc/server";
import { WEBAPP_URL } from "./constants";
export const samlDatabaseUrl = process.env.SAML_DATABASE_URL || "";
export const samlLoginUrl = WEBAPP_URL;
export const isSAMLLoginEnabled = samlDatabaseUrl.length > 0;
export const samlTenantID = "Cal.com";
export const samlProductID = "Cal.com";
export const samlAudience = "https://saml.cal.com";
export const samlPath = "/api/auth/saml/callback";
export const hostedCal = Boolean(HOSTED_CAL_FEATURES);
export const tenantPrefix = "team-";
const samlAdmins = (process.env.SAML_ADMINS || "").split(",");
export const hostedCal = WEBAPP_URL === "https://app.cal.com";
export const tenantPrefix = "team-";
export const isSAMLAdmin = (email: string) => {
for (const admin of samlAdmins) {
@ -57,3 +58,49 @@ export const samlTenantProduct = async (prisma: PrismaClient, email: string) =>
product: samlProductID,
};
};
export const canAccess = async (
user: { id: number; plan: UserPlan; email: string },
teamId: number | null
) => {
const { id: userId, plan, email } = user;
if (!isSAMLLoginEnabled) {
return {
message: "To enable this feature, add value for `SAML_DATABASE_URL` and `SAML_ADMINS` to your `.env`",
access: false,
};
}
// Hosted
if (HOSTED_CAL_FEATURES) {
if (teamId === null || !(await isTeamAdmin(userId, teamId))) {
return {
message: "dont_have_permission",
access: false,
};
}
if (plan != UserPlan.PRO) {
return {
message: "app_upgrade_description",
access: false,
};
}
}
// Self-hosted
if (!HOSTED_CAL_FEATURES) {
if (!isSAMLAdmin(email)) {
return {
message: "dont_have_permission",
access: false,
};
}
}
return {
message: "success",
access: true,
};
};

View File

@ -0,0 +1,39 @@
import { useRouter } from "next/router";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { trpc } from "@calcom/trpc/react";
import SkeletonLoader from "@calcom/ui/v2/core/apps/SkeletonLoader";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
import SAMLConfiguration from "../components/SAMLConfiguration";
const SAMLSSO = () => {
const router = useRouter();
if (!HOSTED_CAL_FEATURES) {
router.push("/404");
}
const teamId = Number(router.query.id);
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId }], {
onError: () => {
router.push("/settings");
},
});
if (isLoading) {
return <SkeletonLoader />;
}
if (!team) {
router.push("/404");
return;
}
return <SAMLConfiguration teamId={teamId} />;
};
SAMLSSO.getLayout = getLayout;
export default SAMLSSO;

View File

@ -0,0 +1,22 @@
import { useRouter } from "next/router";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
import SAMLConfiguration from "../components/SAMLConfiguration";
const SAMLSSO = () => {
const router = useRouter();
if (HOSTED_CAL_FEATURES) {
router.push("/404");
}
const teamId = null;
return <SAMLConfiguration teamId={teamId} />;
};
SAMLSSO.getLayout = getLayout;
export default SAMLSSO;

View File

@ -1,40 +0,0 @@
import jackson, { IAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
import { WEBAPP_URL } from "./constants";
import { samlDatabaseUrl } from "./saml";
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
const opts: JacksonOption = {
externalUrl: WEBAPP_URL,
samlPath: "/api/auth/saml/callback",
db: {
engine: "sql",
type: "postgres",
url: samlDatabaseUrl,
encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY,
},
samlAudience: "https://saml.cal.com",
};
let apiController: IAPIController;
let oauthController: IOAuthController;
const g = global as any;
export default async function init() {
if (!g.apiController || !g.oauthController) {
const ret = await jackson(opts);
apiController = ret.apiController;
oauthController = ret.oauthController;
g.apiController = apiController;
g.oauthController = oauthController;
} else {
apiController = g.apiController;
oauthController = g.oauthController;
}
return {
apiController,
oauthController,
};
}

View File

@ -14,25 +14,15 @@ import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/Cale
import { DailyLocationType } from "@calcom/core/location";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
import { CAL_URL } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import jackson from "@calcom/lib/jackson";
import {
hostedCal,
isSAMLAdmin,
isSAMLLoginEnabled,
samlProductID,
samlTenantID,
samlTenantProduct,
tenantPrefix,
} from "@calcom/lib/saml";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { getTranslation } from "@calcom/lib/server/i18n";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import {
deleteWebUser as syncServicesDeleteWebUser,
@ -50,6 +40,7 @@ import { authRouter } from "./viewer/auth";
import { availabilityRouter } from "./viewer/availability";
import { bookingsRouter } from "./viewer/bookings";
import { eventTypesRouter } from "./viewer/eventTypes";
import { samlRouter } from "./viewer/saml";
import { slotsRouter } from "./viewer/slots";
import { viewerTeamsRouter } from "./viewer/teams";
import { webhookRouter } from "./viewer/webhook";
@ -1043,99 +1034,6 @@ const loggedInViewerRouter = createProtectedRouter()
}
},
})
.query("showSAMLView", {
input: z.object({
teamsView: z.boolean(),
teamId: z.union([z.number(), z.null(), z.undefined()]),
}),
async resolve({ input, ctx }) {
const { user } = ctx;
const { teamsView, teamId } = input;
if ((teamsView && !hostedCal) || (!teamsView && hostedCal)) {
return {
isSAMLLoginEnabled: false,
hostedCal,
};
}
let enabled = isSAMLLoginEnabled;
// in teams view we already check for isAdmin
if (teamsView) {
enabled = enabled && user.plan === "PRO";
} else {
enabled = enabled && isSAMLAdmin(user.email);
}
let provider;
if (enabled) {
const { apiController } = await jackson();
try {
const resp = await apiController.getConfig({
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
provider = resp.provider;
} catch (err) {
console.error("Error getting SAML config", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "SAML configuration fetch failed" });
}
}
return {
isSAMLLoginEnabled: enabled,
hostedCal,
provider,
};
},
})
.mutation("updateSAMLConfig", {
input: z.object({
encodedRawMetadata: z.string(),
teamId: z.union([z.number(), z.null(), z.undefined()]),
}),
async resolve({ ctx, input }) {
const { encodedRawMetadata, teamId } = input;
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const { apiController } = await jackson();
try {
return await apiController.config({
encodedRawMetadata,
defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`,
redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]),
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
} catch (err) {
console.error("Error setting SAML config", err);
throw new TRPCError({ code: "BAD_REQUEST" });
}
},
})
.mutation("deleteSAMLConfig", {
input: z.object({
teamId: z.union([z.number(), z.null(), z.undefined()]),
}),
async resolve({ ctx, input }) {
const { teamId } = input;
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const { apiController } = await jackson();
try {
return await apiController.deleteConfig({
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
} catch (err) {
console.error("Error deleting SAML configuration", err);
throw new TRPCError({ code: "BAD_REQUEST" });
}
},
})
.mutation("submitFeedback", {
input: z.object({
rating: z.string(),
@ -1532,6 +1430,7 @@ export const viewerRouter = createRouter()
.merge("apiKeys.", apiKeysRouter)
.merge("slots.", slotsRouter)
.merge("workflows.", workflowsRouter)
.merge("saml.", samlRouter)
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
// After that there would just one merge call here for all the apps.

View File

@ -0,0 +1,121 @@
import { z } from "zod";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { samlProductID, samlTenantID, tenantPrefix, canAccess } from "@calcom/features/ee/sso/lib/saml";
import { TRPCError } from "@trpc/server";
import { createProtectedRouter } from "../../createRouter";
export const samlRouter = createProtectedRouter()
// Retrieve SAML Connection
.query("get", {
input: z.object({
teamId: z.union([z.number(), z.null()]),
}),
async resolve({ ctx, input }) {
const { connectionController, samlSPConfig } = await jackson();
const { teamId } = input;
const { message, access } = await canAccess(ctx.user, teamId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
// Retrieve the SP SAML Config
const SPConfig = samlSPConfig.get();
const response = {
provider: "",
acsUrl: SPConfig.acsUrl,
entityId: SPConfig.entityId,
};
try {
const connections = await connectionController.getConnections({
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
if (connections.length > 0 && "idpMetadata" in connections[0]) {
response["provider"] = connections[0].idpMetadata.provider;
}
} catch (err) {
console.error("Error getting SAML config", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SAML Connection failed." });
}
return response;
},
})
// Update the SAML Connection
.mutation("update", {
input: z.object({
encodedRawMetadata: z.string(),
teamId: z.union([z.number(), z.null()]),
}),
async resolve({ ctx, input }) {
const { connectionController } = await jackson();
const { encodedRawMetadata, teamId } = input;
const { message, access } = await canAccess(ctx.user, teamId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
try {
return await connectionController.createSAMLConnection({
encodedRawMetadata,
defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`,
redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]),
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
} catch (err) {
console.error("Error updating SAML connection", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "Updating SAML Connection failed." });
}
},
})
// Delete the SAML Connection
.mutation("delete", {
input: z.object({
teamId: z.union([z.number(), z.null()]),
}),
async resolve({ ctx, input }) {
const { connectionController } = await jackson();
const { teamId } = input;
const { message, access } = await canAccess(ctx.user, teamId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
try {
return await connectionController.deleteConnections({
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
product: samlProductID,
});
} catch (err) {
console.error("Error deleting SAML connection", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." });
}
},
});

View File

@ -6,6 +6,7 @@ import React, { ComponentProps, useEffect, useState } from "react";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@ -39,7 +40,6 @@ const tabs: VerticalTabItemProps[] = [
href: "/settings/security",
icon: Icon.FiKey,
children: [
//
{ name: "password", href: "/settings/security/password" },
{ name: "2fa_auth", href: "/settings/security/two-factor-auth" },
],
@ -81,6 +81,13 @@ const tabs: VerticalTabItemProps[] = [
},
];
tabs.find((tab) => {
// Add "SAML SSO" to the tab
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
tab.children?.push({ name: "saml_config", href: "/settings/security/sso" });
}
});
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
@ -100,7 +107,6 @@ const SettingsSidebarContainer = ({ className = "" }) => {
const tabsWithPermissions = useTabs();
const [teamMenuState, setTeamMenuState] =
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
const [isLoading, setIsLoading] = useState(true);
const { data: teams } = trpc.useQuery(["viewer.teams.list"]);
@ -236,13 +242,14 @@ const SettingsSidebarContainer = ({ className = "" }) => {
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/>
{/* TODO: Implement saml configuration page */}
{/* <VerticalTabItem
name={t("saml_config")}
href={`${WEBAPP_URL}/settings/teams/${team.id}/samlConfig`}
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/> */}
{HOSTED_CAL_FEATURES && (
<VerticalTabItem
name={t("saml_config")}
href={`/settings/teams/${team.id}/sso`}
textClassNames="px-3 text-gray-900 font-medium text-sm"
disableChevron
/>
)}
</>
)}
</CollapsibleContent>

802
yarn.lock

File diff suppressed because it is too large Load Diff