From 1a20b0a0c6afea060b2d0aa2a3a0c305e5869af6 Mon Sep 17 00:00:00 2001 From: Deepak Prabhakara Date: Thu, 13 Jan 2022 20:05:23 +0000 Subject: [PATCH] Add log in with Google and SAML (#1192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add log in with Google * Fix merge conflicts * Merge branch 'main' into feature/copy-add-identity-provider # Conflicts: # pages/api/auth/[...nextauth].tsx # pages/api/auth/forgot-password.ts # pages/settings/security.tsx # prisma/schema.prisma # public/static/locales/en/common.json * WIP: SAML login * fixed login * fixed verified_email check for Google * tweaks to padding * added BoxyHQ SAML service to local docker-compose * identityProvider is missing from the select clause * user may be undefined * fix for yarn build * Added SAML configuration to Settings -> Security page * UI tweaks * get saml login flag from the server * UI tweaks * moved SAMLConfiguration to a component in ee * updated saml migration date * fixed merge conflict * fixed merge conflict * lint fixes * check-types fixes * check-types fixes * fixed type errors * updated docker image for SAML Jackson * added api keys config * added default values for SAML_TENANT_ID and SAML_PRODUCT_ID * - move all env vars related to saml into a separate file for easy access - added SAML_ADMINS comma separated list of emails that will be able to configure the SAML metadata * cleanup after merging main * revert mistake during merge * revert mistake during merge * set info text to indicate SAML has been configured. * tweaks to text * tweaks to text * i18n text * i18n text * tweak * use a separate db for saml to avoid Prisma schema being out of sync * use separate docker-compose file for saml * padding tweak * Prepare for implementing SAML login for the hosted solution * WIP: Support for SAML in the hosted solution * teams view has changed, adjusting saml changes accordingly * enabled SAML only for PRO plan * if user was invited and signs in via saml/google then update the user record * WIP: embed saml lib * 302 instead of 307 * no separate docker-compose file for saml * - ogs cleanup - type fixes * fixed types for jackson * cleaned up cors, not needed by the oauth flow * updated jackson to support encryption at rest * updated saml-jackson lib * allow only the required http methods * fixed issue with latest merge with main * - Added instructions for deploying SAML support - Tweaked SAML audience identifier * fixed check for hosted Cal instance * Added a new route to initiate Google and SAML login flows * updated saml-jackson lib (node engine version is now 14.x or above) * moved SAML instructions from Google Docs to a docs file * moved randomString to lib * comment SAML_DATABASE_URL and SAML_ADMINS in .env.example so that default is SAML off. * fixed path to randomString * updated @boxyhq/saml-jackson to v0.3.0 * fixed TS errors * tweaked SAML config UI * fixed types * added e2e test for Google login * setup secrets for Google login test * test for OAuth login buttons (Google and SAML) * enabled saml for the test * added test for SAML config UI * fixed nextauth import * use pkce flow * tweaked NextAuth config for saml * updated saml-jackson * added ability to delete SAML configuration * SAML variables explainers and refactoring * Prevents constant collision * Var name changes * Env explainers * better validation for email Co-authored-by: Omar López * enabled GOOGLE_API_CREDENTIALS in e2e tests (Github Actions secret) * cleanup (will create an issue to handle forgot password for Google and SAML identities) Co-authored-by: Chris <76668588+bytesbuffer@users.noreply.github.com> Co-authored-by: Omar López --- .env.example | 26 +- .github/workflows/e2e.yml | 2 + docker-compose.yml | 1 + docs/saml-setup.md | 27 + ee/README.md | 11 + ee/components/saml/Configuration.tsx | 162 ++ lib/auth.ts | 8 + lib/jackson.ts | 41 + lib/random.ts | 9 + lib/saml.ts | 59 + package.json | 1 + pages/api/auth/[...nextauth].tsx | 357 ++- pages/api/auth/changepw.ts | 10 +- pages/api/auth/forgot-password.ts | 1 + pages/api/auth/saml/authorize.ts | 21 + pages/api/auth/saml/callback.ts | 21 + pages/api/auth/saml/token.ts | 21 + pages/api/auth/saml/userinfo.ts | 47 + pages/api/auth/signup.ts | 4 + pages/api/auth/two-factor/totp/setup.ts | 6 + pages/auth/login.tsx | 71 +- pages/auth/signup.tsx | 6 +- pages/auth/sso/[provider].tsx | 84 + pages/settings/security.tsx | 30 +- pages/settings/teams/[id].tsx | 3 + playwright/event-types.test.ts | 2 +- playwright/lib/testUtils.ts | 10 - playwright/login.oauth.test.ts | 28 + playwright/saml.test.ts | 11 + .../migration.sql | 6 + .../migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 9 + public/static/locales/en/common.json | 19 +- public/static/locales/es/common.json | 2 +- public/static/locales/ja/common.json | 2 +- public/static/locales/pt/common.json | 2 +- public/static/locales/ro/common.json | 2 +- public/static/locales/ru/common.json | 2 +- server/createContext.ts | 1 + server/lib/constants.ts | 5 + server/routers/viewer.tsx | 116 +- server/routers/viewer/teams.tsx | 21 +- yarn.lock | 1944 +++++++++++++---- 44 files changed, 2727 insertions(+), 488 deletions(-) create mode 100644 docs/saml-setup.md create mode 100644 ee/components/saml/Configuration.tsx create mode 100644 lib/jackson.ts create mode 100644 lib/random.ts create mode 100644 lib/saml.ts create mode 100644 pages/api/auth/saml/authorize.ts create mode 100644 pages/api/auth/saml/callback.ts create mode 100644 pages/api/auth/saml/token.ts create mode 100644 pages/api/auth/saml/userinfo.ts create mode 100644 pages/auth/sso/[provider].tsx create mode 100644 playwright/login.oauth.test.ts create mode 100644 playwright/saml.test.ts create mode 100644 prisma/migrations/20211110142845_add_identity_provider_columns/migration.sql create mode 100644 prisma/migrations/20211112145539_add_saml_login/migration.sql create mode 100644 prisma/migrations/20211210182230_add_invited_to/migration.sql create mode 100644 server/lib/constants.ts diff --git a/.env.example b/.env.example index b3b79157..7ee71a33 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 bbd77a9f..c3f1ce12 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 55d88a66..7e091126 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 00000000..29792aec --- /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 b67429ee..f2f9aa48 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 00000000..9482199b --- /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 && } + +