Skip to content

Commit

Permalink
Add log in with Google and SAML (calcom#1192)
Browse files Browse the repository at this point in the history
* 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 <zomars@me.com>

* 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 <zomars@me.com>
  • Loading branch information
3 people committed Jan 13, 2022
1 parent ffc0f46 commit 1a20b0a
Show file tree
Hide file tree
Showing 44 changed files with 2,723 additions and 484 deletions.
26 changes: 19 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -10,7 +10,14 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
# DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions docs/saml-setup.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions ee/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<CALENDSO URL>/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.
162 changes: 162 additions & 0 deletions ee/components/saml/Configuration.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<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;

mutation.mutate({
rawMetadata: rawMetadata,
teamId,
});
}

async function deleteSAMLConfigHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
event.preventDefault();

deleteMutation.mutate({
teamId,
});
}

const { t } = useLocale();
return (
<>
<hr className="mt-8" />

{isSAMLLoginEnabled ? (
<>
<div className="mt-6">
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">
{t("saml_configuration")}
<Badge className="text-xs ml-2" variant={samlConfig ? "success" : "gray"}>
{samlConfig ? t("enabled") : t("disabled")}
</Badge>
{samlConfig ? (
<>
<Badge className="text-xs ml-2" 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}
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
placeholder={t("saml_configuration_placeholder")}
/>

<div className="flex justify-end py-8">
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("save")}
</button>
</div>
<hr className="mt-4" />
</form>
</>
) : null}
</>
);
}
8 changes: 8 additions & 0 deletions lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IdentityProvider } from "@prisma/client";
import { compare, hash } from "bcryptjs";
import { Session } from "next-auth";
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
Expand Down Expand Up @@ -30,4 +31,11 @@ export enum ErrorCode {
IncorrectTwoFactorCode = "incorrect-two-factor-code",
InternalServerError = "internal-server-error",
NewPasswordMatchesOld = "new-password-matches-old",
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
}

export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
[IdentityProvider.CAL]: "Cal",
[IdentityProvider.GOOGLE]: "Google",
[IdentityProvider.SAML]: "SAML",
};
41 changes: 41 additions & 0 deletions lib/jackson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
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,
};
}
9 changes: 9 additions & 0 deletions lib/random.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const randomString = function (length = 12) {
let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};

0 comments on commit 1a20b0a

Please sign in to comment.