Support OIDC (#6661)

* Support OIDC

* tweak to the Configure button

* fix the type import
This commit is contained in:
Kiran K
2023-01-24 13:02:43 -07:00
committed by GitHub
parent 865663646a
commit abb5fd36f3
15 changed files with 685 additions and 307 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"yarn": ">=1.19.0 < 2.0.0"
},
"dependencies": {
"@boxyhq/saml-jackson": "1.3.6",
"@boxyhq/saml-jackson": "1.7.1",
"@calcom/app-store": "*",
"@calcom/app-store-cli": "*",
"@calcom/core": "*",
+37
View File
@@ -0,0 +1,37 @@
import { NextApiRequest, NextApiResponse } from "next";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@lib/core/http/error";
// This is the callback endpoint for the OIDC provider
// A team must set this endpoint in the OIDC provider's configuration
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") {
return res.status(400).send("Method not allowed");
}
const { code, state } = req.query as {
code: string;
state: string;
};
const { oauthController } = await jackson();
try {
const { redirect_url } = await oauthController.oidcAuthzResponse({ code, state });
if (!redirect_url) {
throw new HttpError({
message: "No redirect URL found",
statusCode: 500,
});
}
return res.redirect(302, redirect_url);
} catch (err) {
const { message, statusCode = 500 } = err as HttpError;
return res.status(statusCode).send(message);
}
}
+20 -10
View File
@@ -1375,15 +1375,6 @@
"error_editing_availability": "Error editing availability",
"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",
@@ -1523,5 +1514,24 @@
"reporting": "Reporting",
"reporting_feature": "See all incoming from data and download it as a CSV",
"teams_plan_required": "Teams plan required",
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature."
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.",
"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_oidc_heading": "SSO with OIDC",
"sso_oidc_description": "Configure OIDC SSO with Identity Provider of your choice.",
"sso_oidc_configuration_title": "OIDC Configuration",
"sso_oidc_configuration_description": "Configure OIDC connection to your identity provider. You can find the required information in your identity provider.",
"sso_oidc_callback_copied": "Callback URL copied",
"sso_saml_heading": "SSO with SAML",
"sso_saml_description": "Configure SAML SSO with Identity Provider of your choice.",
"sso_saml_configuration_title": "SAML Configuration",
"sso_saml_configuration_description": "Configure SAML connection to your identity provider. You can find the required information in your identity provider.",
"sso_saml_acsurl_copied": "ACS URL copied",
"sso_saml_entityid_copied": "Entity ID copied",
"sso_connection_created_successfully": "{{connectionType}} configuration created successfully",
"sso_connection_deleted_successfully": "{{connectionType}} configuration deleted successfully",
"delete_sso_configuration": "Delete {{connectionType}} configuration",
"delete_sso_configuration_confirmation": "Yes, delete {{connectionType}} configuration",
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com."
}
@@ -1,85 +0,0 @@
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 { Button, DialogFooter, Form, showToast, TextArea } from "@calcom/ui";
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.viewer.saml.update.useMutation({
async onSuccess() {
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
await utils.viewer.saml.get.invalidate();
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>
);
}
@@ -0,0 +1,168 @@
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
showToast,
Tooltip,
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Label,
} from "@calcom/ui";
import { ClipboardCopyIcon } from "@calcom/ui/components/icon";
export default function ConnectionInfo({
teamId,
connection,
}: {
teamId: number | null;
connection: SSOConnection;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const connectionType = connection.type.toUpperCase();
// Delete SSO connection
const mutation = trpc.viewer.saml.delete.useMutation({
async onSuccess() {
showToast(
t("sso_connection_deleted_successfully", {
connectionType,
}),
"success"
);
await utils.viewer.saml.get.invalidate();
},
});
const deleteConnection = async () => {
mutation.mutate({
teamId,
});
};
return (
<div>
{connection.type === "saml" ? (
<SAMLInfo acsUrl={connection.acsUrl} entityId={connection.entityId} />
) : (
<OIDCInfo callbackUrl={connection.callbackUrl} />
)}
<hr className="my-6 border-neutral-200" />
<div className="flex flex-col space-y-3">
<Label>{t("danger_zone")}</Label>
<Dialog>
<div>
<DialogTrigger asChild>
<Button color="destructive">{t("delete_sso_configuration", { connectionType })}</Button>
</DialogTrigger>
</div>
<ConfirmationDialogContent
variety="danger"
title={t("delete_sso_configuration", { connectionType })}
confirmBtnText={t("delete_sso_configuration_confirmation", { connectionType })}
onConfirm={deleteConnection}>
{t("delete_sso_configuration_confirmation_description", { appName: APP_NAME, connectionType })}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
);
}
// Connection info for SAML
const SAMLInfo = ({ acsUrl, entityId }: { acsUrl: string | null; entityId: string | null }) => {
const { t } = useLocale();
if (!acsUrl || !entityId) {
return null;
}
return (
<div className="space-y-6">
<div className="flex flex-col">
<div className="flex">
<Label>ACS URL</Label>
</div>
<div className="flex">
<code className="flex w-full items-center truncate rounded rounded-r-none bg-gray-100 pl-2 font-mono text-gray-800">
{acsUrl}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(acsUrl);
showToast(t("sso_saml_acsurl_copied"), "success");
}}
type="button"
className="rounded-l-none py-[19px] text-base ">
<ClipboardCopyIcon className="h-5 w-5 text-gray-100 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col">
<div className="flex">
<Label>Entity ID</Label>
</div>
<div className="flex">
<code className="flex w-full items-center truncate rounded rounded-r-none bg-gray-100 pl-2 font-mono text-gray-800">
{entityId}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(entityId);
showToast(t("sso_saml_entityid_copied"), "success");
}}
type="button"
className="rounded-l-none py-[19px] text-base ">
<ClipboardCopyIcon className="h-5 w-5 text-gray-100 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
</div>
);
};
// Connection info for OIDC
const OIDCInfo = ({ callbackUrl }: { callbackUrl: string | null }) => {
const { t } = useLocale();
if (!callbackUrl) {
return null;
}
return (
<div>
<div className="flex flex-col">
<div className="flex">
<Label>Callback URL</Label>
</div>
<div className="flex">
<code className="flex w-full items-center truncate rounded rounded-r-none bg-gray-100 pl-2 font-mono text-gray-800">
{callbackUrl}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(callbackUrl);
showToast(t("sso_oidc_callback_copied"), "success");
}}
type="button"
className="rounded-l-none py-[19px] text-base ">
<ClipboardCopyIcon className="h-5 w-5 text-gray-100 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
</div>
);
};
@@ -0,0 +1,168 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Button, DialogFooter, Form, showToast, TextField, Dialog, DialogContent } from "@calcom/ui";
type FormValues = {
clientId: string;
clientSecret: string;
wellKnownUrl: string;
};
export default function OIDCConnection({
teamId,
connection,
}: {
teamId: number | null;
connection: SSOConnection | null;
}) {
const { t } = useLocale();
const [openModal, setOpenModal] = useState(false);
return (
<div>
<div className="flex flex-col sm:flex-row">
<div>
<h2 className="font-medium">{t("sso_oidc_heading")}</h2>
<p className="text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
{t("sso_oidc_description")}
</p>
</div>
{!connection && (
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">
<Button color="secondary" onClick={() => setOpenModal(true)}>
{t("configure")}
</Button>
</div>
)}
</div>
<CreateConnectionDialog teamId={teamId} openModal={openModal} setOpenModal={setOpenModal} />
</div>
);
}
const CreateConnectionDialog = ({
teamId,
openModal,
setOpenModal,
}: {
teamId: number | null;
openModal: boolean;
setOpenModal: (open: boolean) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const telemetry = useTelemetry();
const form = useForm<FormValues>();
const mutation = trpc.viewer.saml.updateOIDC.useMutation({
async onSuccess() {
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
showToast(
t("sso_connection_created_successfully", {
connectionType: "OIDC",
}),
"success"
);
setOpenModal(false);
await utils.viewer.saml.get.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent type="creation">
<Form
form={form}
handleSubmit={(values) => {
const { clientId, clientSecret, wellKnownUrl } = values;
mutation.mutate({
teamId,
clientId,
clientSecret,
wellKnownUrl,
});
}}>
<div className="mb-10 mt-1">
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">
{t("sso_oidc_configuration_title")}
</h2>
<p className="mt-1 mb-5 text-sm text-gray-500">{t("sso_oidc_configuration_description")}</p>
</div>
<div className="space-y-5">
<Controller
control={form.control}
name="clientId"
render={({ field: { value } }) => (
<TextField
name="clientId"
label="Client id"
value={value}
onChange={(e) => {
form.setValue("clientId", e?.target.value);
}}
type="text"
required
/>
)}
/>
<Controller
control={form.control}
name="clientSecret"
render={({ field: { value } }) => (
<TextField
name="clientSecret"
label="Client secret"
value={value}
onChange={(e) => {
form.setValue("clientSecret", e?.target.value);
}}
type="text"
required
/>
)}
/>
<Controller
control={form.control}
name="wellKnownUrl"
render={({ field: { value } }) => (
<TextField
name="wellKnownUrl"
label="Well-Known URL"
value={value}
onChange={(e) => {
form.setValue("wellKnownUrl", e?.target.value);
}}
type="text"
required
/>
)}
/>
</div>
<DialogFooter>
<Button
type="button"
color="secondary"
onClick={() => {
setOpenModal(false);
}}
tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,184 +0,0 @@
import { useState } from "react";
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
import ConfigDialogForm from "@calcom/features/ee/sso/components/ConfigDialogForm";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Alert,
Badge,
Button,
ConfirmationDialogContent,
Dialog,
DialogContent,
DialogTrigger,
Label,
Meta,
showToast,
AppSkeletonLoader as SkeletonLoader,
} from "@calcom/ui";
import { ClipboardCopyIcon, FiDatabase, FiTrash2 } from "@calcom/ui/components/icon";
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.viewer.saml.get.useQuery(
{ teamId },
{
onError: (err) => {
setHasError(true);
setErrorMessage(err.message);
},
onSuccess: () => {
setHasError(false);
setErrorMessage("");
},
}
);
const mutation = trpc.viewer.saml.delete.useMutation({
async onSuccess() {
await utils.viewer.saml.get.invalidate();
showToast(t("saml_config_deleted_successfully"), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const deleteConnection = () => {
mutation.mutate({
teamId,
});
};
if (isLoading) {
return <SkeletonLoader title={t("saml_config")} description={t("saml_description")} />;
}
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={FiDatabase}
onClick={() => {
setConfigModal(true);
}}>
{t("saml_btn_configure")}
</Button>
</div>
</div>
{/* Service Provider Details */}
{connection && connection.provider && (
<>
<hr className="my-8 border 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-gray-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-gray-100" />
{t("copy")}
</Button>
</div>
</div>
</>
)}
{/* Danger Zone and Delete Confirmation */}
{connection && connection.provider && (
<>
<hr className="my-8 border border-gray-200" />
<div className="mb-3 text-base font-semibold">{t("danger_zone")}</div>
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={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", { appName: APP_NAME })}
</ConfirmationDialogContent>
</Dialog>
</>
)}
{/* Add/Update SAML Connection */}
<Dialog open={configModal} onOpenChange={setConfigModal}>
<DialogContent type="creation">
<ConfigDialogForm handleClose={() => setConfigModal(false)} teamId={teamId} />
</DialogContent>
</Dialog>
</LicenseRequired>
</>
);
}
@@ -0,0 +1,131 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Button, DialogFooter, Form, showToast, TextArea, Dialog, DialogContent } from "@calcom/ui";
interface FormValues {
metadata: string;
}
export default function SAMLConnection({
teamId,
connection,
}: {
teamId: number | null;
connection: SSOConnection | null;
}) {
const { t } = useLocale();
const [openModal, setOpenModal] = useState(false);
return (
<div>
<div className="flex flex-col sm:flex-row">
<div>
<h2 className="font-medium">{t("sso_saml_heading")}</h2>
<p className="text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
{t("sso_saml_description")}
</p>
</div>
{!connection && (
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">
<Button color="secondary" onClick={() => setOpenModal(true)}>
Configure
</Button>
</div>
)}
</div>
<CreateConnectionDialog teamId={teamId} openModal={openModal} setOpenModal={setOpenModal} />
</div>
);
}
const CreateConnectionDialog = ({
teamId,
openModal,
setOpenModal,
}: {
teamId: number | null;
openModal: boolean;
setOpenModal: (open: boolean) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const telemetry = useTelemetry();
const form = useForm<FormValues>();
const mutation = trpc.viewer.saml.update.useMutation({
async onSuccess() {
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
showToast(
t("sso_connection_created_successfully", {
connectionType: "SAML",
}),
"success"
);
setOpenModal(false);
await utils.viewer.saml.get.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent type="creation">
<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("sso_saml_configuration_title")}
</h2>
<p className="mt-1 mb-5 text-sm text-gray-500">{t("sso_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={() => {
setOpenModal(false);
}}
tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,61 @@
import { useState } from "react";
import ConnectionInfo from "@calcom/ee/sso/components/ConnectionInfo";
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
import OIDCConnection from "@calcom/features/ee/sso/components/OIDCConnection";
import SAMLConnection from "@calcom/features/ee/sso/components/SAMLConnection";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { AppSkeletonLoader as SkeletonLoader, Meta, Alert } from "@calcom/ui";
export default function SSOConfiguration({ teamId }: { teamId: number | null }) {
const [errorMessage, setErrorMessage] = useState("");
const { t } = useLocale();
const { data: connection, isLoading } = trpc.viewer.saml.get.useQuery(
{ teamId },
{
onError: (err) => {
setErrorMessage(err.message);
},
}
);
if (isLoading) {
return <SkeletonLoader />;
}
if (errorMessage) {
return (
<>
<Meta title={t("saml_config")} description={t("saml_description")} />
<Alert severity="warning" message={t(errorMessage)} className="mb-4 " />
</>
);
}
// No connection found
if (!connection) {
return (
<LicenseRequired>
<div className="flex flex-col space-y-10">
<SAMLConnection teamId={teamId} connection={null} />
<OIDCConnection teamId={teamId} connection={null} />
</div>
</LicenseRequired>
);
}
return (
<LicenseRequired>
<div className="flex flex-col space-y-6">
{connection.type === "saml" ? (
<SAMLConnection teamId={teamId} connection={connection} />
) : (
<OIDCConnection teamId={teamId} connection={connection} />
)}
<ConnectionInfo teamId={teamId} connection={connection} />
</div>
</LicenseRequired>
);
}
+2 -1
View File
@@ -7,13 +7,14 @@ import jackson, {
import { WEBAPP_URL } from "@calcom/lib/constants";
import { samlDatabaseUrl, samlAudience, samlPath } from "./saml";
import { samlDatabaseUrl, samlAudience, samlPath, oidcPath } 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,
oidcPath,
db: {
engine: "sql",
type: "postgres",
+9
View File
@@ -1,3 +1,4 @@
import type { SAMLSSORecord, OIDCSSORecord } from "@boxyhq/saml-jackson";
import { PrismaClient } from "@prisma/client";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
@@ -11,6 +12,7 @@ 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 oidcPath = "/api/auth/oidc";
export const hostedCal = Boolean(HOSTED_CAL_FEATURES);
export const tenantPrefix = "team-";
@@ -94,3 +96,10 @@ export const canAccess = async (user: { id: number; email: string }, teamId: num
access: true,
};
};
export type SSOConnection = (SAMLSSORecord | OIDCSSORecord) & {
type: string;
acsUrl: string | null;
entityId: string | null;
callbackUrl: string | null;
};
@@ -1,13 +1,16 @@
import { useRouter } from "next/router";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { AppSkeletonLoader as SkeletonLoader } from "@calcom/ui";
import { Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import SAMLConfiguration from "../components/SAMLConfiguration";
import SSOConfiguration from "../components/SSOConfiguration";
const SAMLSSO = () => {
const { t } = useLocale();
const router = useRouter();
if (!HOSTED_CAL_FEATURES) {
@@ -34,7 +37,12 @@ const SAMLSSO = () => {
return;
}
return <SAMLConfiguration teamId={teamId} />;
return (
<div className="w-full bg-white sm:mx-0 xl:mt-0">
<Meta title={t("sso_configuration")} description={t("sso_configuration_description")} />
<SSOConfiguration teamId={teamId} />
</div>
);
};
SAMLSSO.getLayout = getLayout;
@@ -1,20 +1,26 @@
import { useRouter } from "next/router";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import SAMLConfiguration from "../components/SAMLConfiguration";
import SSOConfiguration from "../components/SSOConfiguration";
const SAMLSSO = () => {
const { t } = useLocale();
const router = useRouter();
if (HOSTED_CAL_FEATURES) {
router.push("/404");
}
const teamId = null;
return <SAMLConfiguration teamId={teamId} />;
return (
<div className="w-full bg-white sm:mx-0 xl:mt-0">
<Meta title={t("sso_configuration")} description={t("sso_configuration_description")} />
<SSOConfiguration teamId={null} />
</div>
);
};
SAMLSSO.getLayout = getLayout;
+2 -2
View File
@@ -43,8 +43,8 @@ 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 { ssoRouter } from "./viewer/sso";
import { viewerTeamsRouter } from "./viewer/teams";
import { webhookRouter } from "./viewer/webhook";
import { workflowsRouter } from "./viewer/workflows";
@@ -1156,7 +1156,7 @@ export const viewerRouter = mergeRouters(
apiKeys: apiKeysRouter,
slots: slotsRouter,
workflows: workflowsRouter,
saml: samlRouter,
saml: ssoRouter,
// 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.
appRoutingForms: app_RoutingForms,
@@ -1,14 +1,20 @@
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 {
samlProductID,
samlTenantID,
tenantPrefix,
canAccess,
oidcPath,
} from "@calcom/features/ee/sso/lib/saml";
import { TRPCError } from "@trpc/server";
import { router, authedProcedure } from "../../trpc";
export const samlRouter = router({
// Retrieve SAML Connection
export const ssoRouter = router({
// Retrieve SSO Connection
get: authedProcedure
.input(
z.object({
@@ -16,8 +22,6 @@ export const samlRouter = router({
})
)
.query(async ({ ctx, input }) => {
const { connectionController, samlSPConfig } = await jackson();
const { teamId } = input;
const { message, access } = await canAccess(ctx.user, teamId);
@@ -29,30 +33,34 @@ export const samlRouter = router({
});
}
const { connectionController, samlSPConfig } = await jackson();
// Retrieve the SP SAML Config
const SPConfig = await 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;
if (connections.length === 0) {
return null;
}
} catch (err) {
console.error("Error getting SAML config", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SAML Connection failed." });
}
return response;
const type = "idpMetadata" in connections[0] ? "saml" : "oidc";
return {
...connections[0],
type,
acsUrl: type === "saml" ? SPConfig.acsUrl : null,
entityId: type === "saml" ? SPConfig.entityId : null,
callbackUrl: type === "oidc" ? `${process.env.NEXT_PUBLIC_WEBAPP_URL}${oidcPath}` : null,
};
} catch (err) {
console.error("Error getting SSO connection", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connection failed." });
}
}),
// Update the SAML Connection
update: authedProcedure
@@ -120,4 +128,44 @@ export const samlRouter = router({
throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." });
}
}),
// Update the OIDC Connection
updateOIDC: authedProcedure
.input(
z.object({
teamId: z.union([z.number(), z.null()]),
clientId: z.string(),
clientSecret: z.string(),
wellKnownUrl: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { teamId, clientId, clientSecret, wellKnownUrl } = input;
const { message, access } = await canAccess(ctx.user, teamId);
if (!access) {
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
const { connectionController } = await jackson();
try {
return await connectionController.createOIDCConnection({
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,
oidcClientId: clientId,
oidcClientSecret: clientSecret,
oidcDiscoveryUrl: wellKnownUrl,
});
} catch (err) {
console.error("Error updating OIDC connection", err);
throw new TRPCError({ code: "BAD_REQUEST", message: "Updating OIDC Connection failed." });
}
}),
});