diff --git a/.env.example b/.env.example index b3b791579d..7ee71a33c5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Set this value to 'agree' to accept our license: +# Set this value to 'agree' to accept our license: # LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE # # Summary of terms: @@ -10,7 +10,14 @@ NEXT_PUBLIC_LICENSE_CONSENT='' # DATABASE_URL='postgresql://:@:/' DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" -GOOGLE_API_CREDENTIALS='secret' +# Needed to enable Google Calendar integrationa and Login with Google +# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS='{}' + +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` above +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +GOOGLE_LOGIN_ENABLED=false BASE_URL='http://localhost:3000' NEXT_PUBLIC_APP_URL='http://localhost:3000' @@ -19,6 +26,11 @@ JWT_SECRET='secret' # This is used so we can bypass emails in auth flows for E2E testing PLAYWRIGHT_SECRET= +# To enable SAML login, set both these variables +# @see https://github.com/calendso/calendso/tree/main/ee#setting-up-saml-login +# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml" +# SAML_ADMINS='pro@example.com' + # @see: https://github.com/calendso/calendso/issues/263 # Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL # NEXTAUTH_URL='http://localhost:3000' @@ -58,11 +70,11 @@ CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0' # Stripe Config NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_... -STRIPE_PRIVATE_KEY= # sk_test_... -STRIPE_CLIENT_ID= # ca_... -STRIPE_WEBHOOK_SECRET= # whsec_... -PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission -PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission +STRIPE_PRIVATE_KEY= # sk_test_... +STRIPE_CLIENT_ID= # ca_... +STRIPE_WEBHOOK_SECRET= # whsec_... +PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission +PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission # Application Key for symmetric encryption and decryption # must be 32 bytes for AES256 encryption algorithm diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bbd77a9fa9..c3f1ce12f2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -31,6 +31,8 @@ jobs: STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} PAYMENT_FEE_PERCENTAGE: 0.005 PAYMENT_FEE_FIXED: 10 + SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso + SAML_ADMINS: pro@example.com # NEXTAUTH_URL: xxx # EMAIL_FROM: xxx # EMAIL_SERVER_HOST: xxx diff --git a/docker-compose.yml b/docker-compose.yml index 55d88a66c9..7e0911264d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: volumes: - db_data:/var/lib/postgresql/data environment: + POSTGRES_DB: "cal-saml" POSTGRES_PASSWORD: "" POSTGRES_HOST_AUTH_METHOD: trust volumes: diff --git a/docs/saml-setup.md b/docs/saml-setup.md new file mode 100644 index 0000000000..29792aec10 --- /dev/null +++ b/docs/saml-setup.md @@ -0,0 +1,27 @@ +# SAML Registration with Identity Providers + +This guide explains the settings you’d need to use to configure SAML with your Identity Provider. Once this is set up you should get an XML metadata file that should then be uploaded on your Cal.com self-hosted instance. + +> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below. + +**Assertion consumer service URL / Single Sign-On URL / Destination URL:** [http://localhost:3000/api/auth/saml/callback](http://localhost:3000/api/auth/saml/callback) [Replace this with the URL for your self-hosted Cal instance] + +**Entity ID / Identifier / Audience URI / Audience Restriction:** https://saml.cal.com + +**Response:** Signed + +**Assertion Signature:** Signed + +**Signature Algorithm:** RSA-SHA256 + +**Assertion Encryption:** Unencrypted + +**Mapping Attributes / Attribute Statements:** + +id -> user.id + +email -> user.email + +firstName -> user.firstName + +lastName -> user.lastName diff --git a/ee/README.md b/ee/README.md index b67429eeda..f2f9aa488e 100644 --- a/ee/README.md +++ b/ee/README.md @@ -25,3 +25,14 @@ The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the pl 6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `/api/integrations/stripepayment/webhook` as webhook for connected applications. 7. Select all `payment_intent` events for the webhook. 8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file. + +## Setting up SAML login + +1. Set SAML_DATABASE_URL to a postgres database. Please use a different database than the main Cal instance since the migrations are separate for this database. For example `postgresql://postgres:@localhost:5450/cal-saml` +2. Set SAML_ADMINS to a comma separated list of admin emails from where the SAML metadata can be uploaded and configured. +3. Create a SAML application with your Identity Provider (IdP) using the instructions here - [SAML Setup](../docs/saml-setup.md) +4. Remember to configure access to the IdP SAML app for all your users (who need access to Cal). +5. You will need the XML metadata from your IdP later, so keep it accessible. +6. Log in to one of the admin accounts configured in SAML_ADMINS and then navigate to Settings -> Security. +7. You should see a SAML configuration section, copy and paste the XML metadata from step 5 and click on Save. +8. Your provisioned users can now log into Cal using SAML. diff --git a/ee/components/saml/Configuration.tsx b/ee/components/saml/Configuration.tsx new file mode 100644 index 0000000000..9482199b17 --- /dev/null +++ b/ee/components/saml/Configuration.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState, useRef } from "react"; + +import { useLocale } from "@lib/hooks/useLocale"; +import showToast from "@lib/notification"; +import { trpc } from "@lib/trpc"; + +import { Dialog, DialogTrigger } from "@components/Dialog"; +import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; +import { Alert } from "@components/ui/Alert"; +import Badge from "@components/ui/Badge"; +import Button from "@components/ui/Button"; + +export default function SAMLConfiguration({ + teamsView, + teamId, +}: { + teamsView: boolean; + teamId: null | undefined | number; +}) { + const [isSAMLLoginEnabled, setIsSAMLLoginEnabled] = useState(false); + const [samlConfig, setSAMLConfig] = useState(null); + + const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]); + + 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() as React.MutableRefObject; + + const [hasErrors, setHasErrors] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + async function updateSAMLConfigHandler(event: React.FormEvent) { + event.preventDefault(); + + const rawMetadata = samlConfigRef.current.value; + + mutation.mutate({ + rawMetadata: rawMetadata, + teamId, + }); + } + + async function deleteSAMLConfigHandler(event: React.MouseEvent) { + event.preventDefault(); + + deleteMutation.mutate({ + teamId, + }); + } + + const { t } = useLocale(); + return ( + <> +
+ + {isSAMLLoginEnabled ? ( + <> +
+

+ {t("saml_configuration")} + + {samlConfig ? t("enabled") : t("disabled")} + + {samlConfig ? ( + <> + + {samlConfig ? samlConfig : ""} + + + ) : null} +

+
+ + {samlConfig ? ( +
+ + + + + + {t("delete_saml_configuration_confirmation_message")} + + +
+ ) : ( +

{!samlConfig ? t("saml_not_configured_yet") : ""}

+ )} + +

{t("saml_configuration_description")}

+ +
+ {hasErrors && } + +