Add log in with Google and SAML (#1192)
* 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>
This commit is contained in:
parent
ffc0f460a0
commit
1a20b0a0c6
44 changed files with 2727 additions and 488 deletions
14
.env.example
14
.env.example
|
@ -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'
|
||||
|
@ -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'
|
||||
|
|
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
27
docs/saml-setup.md
Normal file
27
docs/saml-setup.md
Normal file
|
@ -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
ee/README.md
11
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 `<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
ee/components/saml/Configuration.tsx
Normal file
162
ee/components/saml/Configuration.tsx
Normal file
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
@ -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
lib/jackson.ts
Normal file
41
lib/jackson.ts
Normal file
|
@ -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
lib/random.ts
Normal file
9
lib/random.ts
Normal file
|
@ -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;
|
||||
};
|
59
lib/saml.ts
Normal file
59
lib/saml.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
|
@ -33,6 +33,7 @@
|
|||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "0.3.3",
|
||||
"@daily-co/daily-js": "^0.21.0",
|
||||
"@headlessui/react": "^1.4.2",
|
||||
"@heroicons/react": "^1.0.5",
|
||||
|
|
|
@ -1,22 +1,20 @@
|
|||
import { IdentityProvider } from "@prisma/client";
|
||||
import NextAuth, { Session } from "next-auth";
|
||||
import { Provider } from "next-auth/providers";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
import { ErrorCode, verifyPassword } from "@lib/auth";
|
||||
import { symmetricDecrypt } from "@lib/crypto";
|
||||
import prisma from "@lib/prisma";
|
||||
import { randomString } from "@lib/random";
|
||||
import { isSAMLLoginEnabled, samlLoginUrl } from "@lib/saml";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
export default NextAuth({
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
secret: process.env.JWT_SECRET,
|
||||
pages: {
|
||||
signIn: "/auth/login",
|
||||
signOut: "/auth/logout",
|
||||
error: "/auth/error", // Error code passed in query string as ?error=
|
||||
},
|
||||
providers: [
|
||||
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
|
||||
const providers: Provider[] = [
|
||||
CredentialsProvider({
|
||||
id: "credentials",
|
||||
name: "Cal.com",
|
||||
|
@ -42,6 +40,10 @@ export default NextAuth({
|
|||
throw new Error(ErrorCode.UserNotFound);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== IdentityProvider.CAL) {
|
||||
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.UserMissingPassword);
|
||||
}
|
||||
|
@ -88,13 +90,111 @@ export default NextAuth({
|
|||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.username = user.username;
|
||||
];
|
||||
|
||||
if (IS_GOOGLE_LOGIN_ENABLED) {
|
||||
providers.push(
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (isSAMLLoginEnabled) {
|
||||
providers.push({
|
||||
id: "saml",
|
||||
name: "BoxyHQ",
|
||||
type: "oauth",
|
||||
version: "2.0",
|
||||
checks: ["pkce", "state"],
|
||||
authorization: {
|
||||
url: `${samlLoginUrl}/api/auth/saml/authorize`,
|
||||
params: {
|
||||
scope: "",
|
||||
response_type: "code",
|
||||
provider: "saml",
|
||||
},
|
||||
},
|
||||
token: {
|
||||
url: `${samlLoginUrl}/api/auth/saml/token`,
|
||||
params: { grant_type: "authorization_code" },
|
||||
},
|
||||
userinfo: `${samlLoginUrl}/api/auth/saml/userinfo`,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id || "",
|
||||
firstName: profile.first_name || "",
|
||||
lastName: profile.last_name || "",
|
||||
email: profile.email || "",
|
||||
name: `${profile.firstName} ${profile.lastName}`,
|
||||
email_verified: true,
|
||||
};
|
||||
},
|
||||
options: {
|
||||
clientId: "dummy",
|
||||
clientSecret: "dummy",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default NextAuth({
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
secret: process.env.JWT_SECRET,
|
||||
pages: {
|
||||
signIn: "/auth/login",
|
||||
signOut: "/auth/logout",
|
||||
error: "/auth/error", // Error code passed in query string as ?error=
|
||||
},
|
||||
providers,
|
||||
callbacks: {
|
||||
async jwt({ token, user, account, profile }) {
|
||||
if (!user) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (account && account.type === "credentials") {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
// The arguments above are from the provider so we need to look up the
|
||||
// user based on those values in order to construct a JWT.
|
||||
if (account && profile && account.type === "oauth" && account.provider) {
|
||||
let idP: IdentityProvider = IdentityProvider.GOOGLE;
|
||||
if (account.provider === "saml") {
|
||||
idP = IdentityProvider.SAML;
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
identityProvider: idP,
|
||||
},
|
||||
{
|
||||
identityProviderId: profile.id as string,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return {
|
||||
id: existingUser.id,
|
||||
username: existingUser.username,
|
||||
email: existingUser.email,
|
||||
};
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
|
@ -108,5 +208,118 @@ export default NextAuth({
|
|||
};
|
||||
return calendsoSession;
|
||||
},
|
||||
async signIn({ user, account, profile }) {
|
||||
// In this case we've already verified the credentials in the authorize
|
||||
// callback so we can sign the user in.
|
||||
if (account.type === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (account.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (account.provider) {
|
||||
let idP: IdentityProvider = IdentityProvider.GOOGLE;
|
||||
if (account.provider === "saml") {
|
||||
idP = IdentityProvider.SAML;
|
||||
}
|
||||
user.email_verified = user.email_verified || profile.email_verified;
|
||||
|
||||
if (!user.email_verified) {
|
||||
return "/auth/error?error=unverified-email";
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [{ identityProvider: idP }, { identityProviderId: user.id as string }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// In this case there's an existing user and their email address
|
||||
// hasn't changed since they last logged in.
|
||||
if (existingUser.email === user.email) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the email address doesn't match, check if an account already exists
|
||||
// with the new email address. If it does, for now we return an error. If
|
||||
// not, update the email of their account and log them in.
|
||||
const userWithNewEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
if (!userWithNewEmail) {
|
||||
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
|
||||
return true;
|
||||
} else {
|
||||
return "/auth/error?error=new-email-conflict";
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no existing user for this identity provider and id, create
|
||||
// a new account. If an account already exists with the incoming email
|
||||
// address return an error for now.
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
// check if user was invited
|
||||
if (
|
||||
!existingUserWithEmail.password &&
|
||||
!existingUserWithEmail.emailVerified &&
|
||||
!existingUserWithEmail.username
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: { email: user.email },
|
||||
data: {
|
||||
// Slugify the incoming name and append a few random characters to
|
||||
// prevent conflicts for users with the same name.
|
||||
username: slugify(user.name) + "-" + randomString(6),
|
||||
emailVerified: new Date(Date.now()),
|
||||
name: user.name,
|
||||
identityProvider: idP,
|
||||
identityProviderId: user.id as string,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
||||
return "/auth/error?error=use-password-login";
|
||||
}
|
||||
|
||||
return "/auth/error?error=use-identity-login";
|
||||
}
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
// Slugify the incoming name and append a few random characters to
|
||||
// prevent conflicts for users with the same name.
|
||||
username: slugify(user.name) + "-" + randomString(6),
|
||||
emailVerified: new Date(Date.now()),
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
identityProvider: idP,
|
||||
identityProviderId: user.id as string,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { IdentityProvider } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import { ErrorCode, hashPassword, verifyPassword } from "../../../lib/auth";
|
||||
import prisma from "../../../lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
if (!session || !session.user || !session.user.email) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
@ -20,6 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
select: {
|
||||
id: true,
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -28,6 +30,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return;
|
||||
}
|
||||
|
||||
if (user.identityProvider !== IdentityProvider.CAL) {
|
||||
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
|
||||
}
|
||||
|
||||
const oldPassword = req.body.oldPassword;
|
||||
const newPassword = req.body.newPassword;
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
select: {
|
||||
name: true,
|
||||
identityProvider: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
|
21
pages/api/auth/saml/authorize.ts
Normal file
21
pages/api/auth/saml/authorize.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { OAuthReqBody } from "@boxyhq/saml-jackson";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import jackson from "@lib/jackson";
|
||||
|
||||
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 { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReqBody);
|
||||
res.redirect(302, redirect_url);
|
||||
} catch (err: any) {
|
||||
console.error("authorize error:", err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
21
pages/api/auth/saml/callback.ts
Normal file
21
pages/api/auth/saml/callback.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
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: any) {
|
||||
console.error("callback error:", err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
21
pages/api/auth/saml/token.ts
Normal file
21
pages/api/auth/saml/token.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
47
pages/api/auth/saml/userinfo.ts
Normal file
47
pages/api/auth/saml/userinfo.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import jackson from "@lib/jackson";
|
||||
|
||||
const extractAuthToken = (req: NextApiRequest) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
const parts = (authHeader || "").split(" ");
|
||||
if (parts.length > 1) {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
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[] = [];
|
||||
arr = arr.concat(req.query.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 });
|
||||
}
|
||||
}
|
|
@ -4,6 +4,8 @@ import { hashPassword } from "@lib/auth";
|
|||
import prisma from "@lib/prisma";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import { IdentityProvider } from ".prisma/client";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return;
|
||||
|
@ -64,11 +66,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
username,
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
create: {
|
||||
username,
|
||||
email: userEmail,
|
||||
password: hashedPassword,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
|
|||
import { symmetricEncrypt } from "@lib/crypto";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import { IdentityProvider } from ".prisma/client";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
|
@ -27,6 +29,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
if (user.identityProvider !== IdentityProvider.CAL) {
|
||||
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
|
||||
}
|
||||
|
|
|
@ -7,16 +7,32 @@ import { useState } from "react";
|
|||
import { ErrorCode, getSession } from "@lib/auth";
|
||||
import { WEBSITE_URL } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import Loader from "@components/Loader";
|
||||
import { EmailInput } from "@components/form/fields";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideProps>) {
|
||||
export default function Login({
|
||||
csrfToken,
|
||||
isGoogleLoginEnabled,
|
||||
isSAMLLoginEnabled,
|
||||
hostedCal,
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
}: {
|
||||
csrfToken: string;
|
||||
isGoogleLoginEnabled: boolean;
|
||||
isSAMLLoginEnabled: boolean;
|
||||
hostedCal: boolean;
|
||||
samlTenantID: string;
|
||||
samlProductID: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
|
@ -31,6 +47,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
[ErrorCode.UserNotFound]: t("no_account_exists"),
|
||||
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
|
||||
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/";
|
||||
|
@ -76,6 +93,15 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
}
|
||||
}
|
||||
|
||||
const mutation = trpc.useMutation("viewer.samlTenantProduct", {
|
||||
onSuccess: (data) => {
|
||||
signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center min-h-screen py-12 bg-neutral-50 sm:px-6 lg:px-8">
|
||||
<HeadSeo title={t("login")} description={t("login")} />
|
||||
|
@ -174,6 +200,42 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
|||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</form>
|
||||
{isGoogleLoginEnabled && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<button
|
||||
data-testid={"google"}
|
||||
onClick={async () => await signIn("google")}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-black bg-secondary-50 hover:bg-secondary-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
{t("signin_with_google")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isSAMLLoginEnabled && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<button
|
||||
data-testid={"saml"}
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!hostedCal) {
|
||||
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID });
|
||||
} else {
|
||||
if (email.length === 0) {
|
||||
setErrorMessage(t("saml_email_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
// hosted solution, fetch tenant and product from the backend
|
||||
mutation.mutate({
|
||||
email,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-black bg-secondary-50 hover:bg-secondary-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
{t("signin_with_saml")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-center text-neutral-600">
|
||||
{t("dont_have_an_account")} {/* replace this with your account creation flow */}
|
||||
|
@ -206,6 +268,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
props: {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
trpcState: ssr.dehydrate(),
|
||||
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
|
||||
isSAMLLoginEnabled,
|
||||
hostedCal,
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm, SubmitHandler, FormProvider } from "react-hook-form";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import prisma from "@lib/prisma";
|
||||
import { isSAMLLoginEnabled } from "@lib/saml";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { EmailField, PasswordField, TextField } from "@components/form/fields";
|
||||
|
@ -13,6 +14,7 @@ import { HeadSeo } from "@components/seo/head-seo";
|
|||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
|
@ -181,6 +183,8 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
|||
|
||||
return {
|
||||
props: {
|
||||
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
|
||||
isSAMLLoginEnabled,
|
||||
email: verificationRequest.identifier,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
|
|
84
pages/auth/sso/[provider].tsx
Normal file
84
pages/auth/sso/[provider].tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import prisma from "@lib/prisma";
|
||||
import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID, samlTenantProduct } from "@lib/saml";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type(props: SSOProviderPageProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (props.provider === "saml") {
|
||||
const email = typeof router.query?.email === "string" ? router.query?.email : null;
|
||||
|
||||
if (!email) {
|
||||
router.push("/auth/error?error=" + "Email not provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!props.isSAMLLoginEnabled) {
|
||||
router.push("/auth/error?error=" + "SAML login not enabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
signIn("saml", {}, { tenant: props.tenant, product: props.product });
|
||||
} else {
|
||||
signIn(props.provider);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
// get query params and typecast them to string
|
||||
// (would be even better to assert them instead of typecasting)
|
||||
const providerParam = asStringOrNull(context.query.provider);
|
||||
const emailParam = asStringOrNull(context.query.email);
|
||||
|
||||
if (!providerParam) {
|
||||
throw new Error(`File is not named sso/[provider]`);
|
||||
}
|
||||
|
||||
let error: string | null = null;
|
||||
|
||||
let tenant = samlTenantID;
|
||||
let product = samlProductID;
|
||||
|
||||
if (providerParam === "saml") {
|
||||
if (!emailParam) {
|
||||
error = "Email not provided";
|
||||
} else {
|
||||
try {
|
||||
const ret = await samlTenantProduct(prisma, emailParam);
|
||||
tenant = ret.tenant;
|
||||
product = ret.product;
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/auth/error?error=" + error,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
provider: providerParam,
|
||||
isSAMLLoginEnabled,
|
||||
hostedCal,
|
||||
tenant,
|
||||
product,
|
||||
error,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,5 +1,8 @@
|
|||
import React from "react";
|
||||
|
||||
import SAMLConfiguration from "@ee/components/saml/Configuration";
|
||||
|
||||
import { identityProviderNameMap } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
|
@ -8,14 +11,37 @@ import Shell from "@components/Shell";
|
|||
import ChangePasswordSection from "@components/security/ChangePasswordSection";
|
||||
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
|
||||
|
||||
import { IdentityProvider } from ".prisma/client";
|
||||
|
||||
export default function Security() {
|
||||
const user = trpc.useQuery(["viewer.me"]).data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Shell heading={t("security")} subtitle={t("manage_account_security")}>
|
||||
<SettingsShell>
|
||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">
|
||||
{t("account_managed_by_identity_provider", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{t("account_managed_by_identity_provider_description", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChangePasswordSection />
|
||||
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SAMLConfiguration teamsView={false} teamId={null} />
|
||||
</SettingsShell>
|
||||
</Shell>
|
||||
);
|
||||
|
|
|
@ -2,6 +2,8 @@ import { PlusIcon } from "@heroicons/react/solid";
|
|||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import SAMLConfiguration from "@ee/components/saml/Configuration";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
@ -77,6 +79,7 @@ export function TeamSettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
<MemberList team={team} members={team.members || []} />
|
||||
{isAdmin ? <SAMLConfiguration teamsView={true} teamId={team.id} /> : null}
|
||||
</div>
|
||||
<div className="w-full px-2 mt-8 ml-2 md:w-3/12 sm:mt-0 min-w-32">
|
||||
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { randomString } from "./lib/testUtils";
|
||||
import { randomString } from "../lib/random";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
|
|
|
@ -6,16 +6,6 @@ export function todo(title: string) {
|
|||
test.skip(title, () => {});
|
||||
}
|
||||
|
||||
export function randomString(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;
|
||||
}
|
||||
|
||||
type Request = IncomingMessage & { body?: unknown };
|
||||
type RequestHandlerOptions = { req: Request; res: ServerResponse };
|
||||
type RequestHandler = (opts: RequestHandlerOptions) => void;
|
||||
|
|
28
playwright/login.oauth.test.ts
Normal file
28
playwright/login.oauth.test.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { test } from "@playwright/test";
|
||||
|
||||
test("Test OAuth login buttons", async ({ page }) => {
|
||||
await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/auth/login`);
|
||||
|
||||
// Check for Google login button, then click through and check for email field
|
||||
await page.waitForSelector("[data-testid=google]");
|
||||
|
||||
await page.click("[data-testid=google]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await page.waitForSelector('input[type="email"]');
|
||||
|
||||
await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/auth/login`);
|
||||
|
||||
await page.waitForSelector("[data-testid=saml]");
|
||||
|
||||
// Check for SAML login button, then click through
|
||||
await page.click("[data-testid=saml]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await page.context().close();
|
||||
});
|
11
playwright/saml.test.ts
Normal file
11
playwright/saml.test.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { test } from "@playwright/test";
|
||||
|
||||
// Using logged in state from globalSteup
|
||||
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
|
||||
|
||||
test("test SAML configuration UI with pro@example.com", async ({ page }) => {
|
||||
// Try to go Security page
|
||||
await page.goto("/settings/security");
|
||||
// It should redirect you to the event-types page
|
||||
await page.waitForSelector("[data-testid=saml_config]");
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "IdentityProvider" AS ENUM ('CAL', 'GOOGLE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "identityProvider" "IdentityProvider" NOT NULL DEFAULT E'CAL',
|
||||
ADD COLUMN "identityProviderId" TEXT;
|
|
@ -0,0 +1,2 @@
|
|||
-- add the new value to the existing type
|
||||
ALTER TYPE "IdentityProvider" ADD VALUE 'SAML';
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "invitedTo" INTEGER;
|
|
@ -71,6 +71,12 @@ enum UserPlan {
|
|||
PRO
|
||||
}
|
||||
|
||||
enum IdentityProvider {
|
||||
CAL
|
||||
GOOGLE
|
||||
SAML
|
||||
}
|
||||
|
||||
model DestinationCalendar {
|
||||
id Int @id @default(autoincrement())
|
||||
integration String
|
||||
|
@ -112,6 +118,9 @@ model User {
|
|||
locale String?
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
identityProvider IdentityProvider @default(CAL)
|
||||
identityProviderId String?
|
||||
invitedTo Int?
|
||||
plan UserPlan @default(PRO)
|
||||
Schedule Schedule[]
|
||||
webhooks Webhook[]
|
||||
|
|
|
@ -574,5 +574,22 @@
|
|||
"set_as_away": "Set yourself as away",
|
||||
"set_as_free": "Disable away status",
|
||||
"user_away": "This user is currently away.",
|
||||
"user_away_description": "The person you are trying to book has set themselves to away, and therefore is not accepting new bookings."
|
||||
"user_away_description": "The person you are trying to book has set themselves to away, and therefore is not accepting new bookings.",
|
||||
"saml_config_updated_successfully": "SAML configuration updated successfully",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ async function getUserFromSession({
|
|||
hideBranding: true,
|
||||
avatar: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
brandColor: true,
|
||||
plan: true,
|
||||
away: true,
|
||||
|
|
5
server/lib/constants.ts
Normal file
5
server/lib/constants.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}";
|
||||
export const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } =
|
||||
JSON.parse(GOOGLE_API_CREDENTIALS)?.web;
|
||||
export const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
|
||||
export const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
|
|
@ -7,6 +7,16 @@ import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
|
|||
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager";
|
||||
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
|
||||
import jackson from "@lib/jackson";
|
||||
import {
|
||||
isSAMLLoginEnabled,
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
isSAMLAdmin,
|
||||
hostedCal,
|
||||
tenantPrefix,
|
||||
samlTenantProduct,
|
||||
} from "@lib/saml";
|
||||
import slugify from "@lib/slugify";
|
||||
import { Schedule } from "@lib/types/schedule";
|
||||
|
||||
|
@ -35,6 +45,17 @@ const publicViewerRouter = createRouter()
|
|||
locale,
|
||||
};
|
||||
},
|
||||
})
|
||||
.mutation("samlTenantProduct", {
|
||||
input: z.object({
|
||||
email: z.string().email(),
|
||||
}),
|
||||
async resolve({ input, ctx }) {
|
||||
const { prisma } = ctx;
|
||||
const { email } = input;
|
||||
|
||||
return await samlTenantProduct(prisma, email);
|
||||
},
|
||||
});
|
||||
|
||||
// routes only available to authenticated users
|
||||
|
@ -55,6 +76,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
createdDate,
|
||||
completedOnboarding,
|
||||
twoFactorEnabled,
|
||||
identityProvider,
|
||||
brandColor,
|
||||
plan,
|
||||
away,
|
||||
|
@ -72,6 +94,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
createdDate,
|
||||
completedOnboarding,
|
||||
twoFactorEnabled,
|
||||
identityProvider,
|
||||
brandColor,
|
||||
plan,
|
||||
away,
|
||||
|
@ -481,7 +504,6 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const schedule = availabilityQuery.reduce(
|
||||
(schedule: Schedule, availability) => {
|
||||
availability.days.forEach((day) => {
|
||||
|
@ -650,6 +672,98 @@ 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({
|
||||
rawMetadata: z.string(),
|
||||
teamId: z.union([z.number(), z.null(), z.undefined()]),
|
||||
}),
|
||||
async resolve({ input }) {
|
||||
const { rawMetadata, teamId } = input;
|
||||
|
||||
const { apiController } = await jackson();
|
||||
|
||||
try {
|
||||
return await apiController.config({
|
||||
rawMetadata,
|
||||
defaultRedirectUrl: `${process.env.BASE_URL}/api/auth/saml/idp`,
|
||||
redirectUrl: JSON.stringify([`${process.env.BASE_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({ input }) {
|
||||
const { teamId } = input;
|
||||
|
||||
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" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const viewerRouter = createRouter()
|
||||
|
|
|
@ -205,16 +205,19 @@ export const viewerTeamsRouter = createProtectedRouter()
|
|||
message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`,
|
||||
});
|
||||
|
||||
// valid email given, create User
|
||||
await ctx.prisma.user.create({ data: { email: input.usernameOrEmail } }).then((invitee) =>
|
||||
ctx.prisma.membership.create({
|
||||
// valid email given, create User and add to team
|
||||
await ctx.prisma.user.create({
|
||||
data: {
|
||||
email: input.usernameOrEmail,
|
||||
invitedTo: input.teamId,
|
||||
teams: {
|
||||
create: {
|
||||
teamId: input.teamId,
|
||||
userId: invitee.id,
|
||||
role: input.role as MembershipRole,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token: string = randomBytes(32).toString("hex");
|
||||
|
||||
|
|
Loading…
Reference in a new issue