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:
Deepak Prabhakara 2022-01-13 20:05:23 +00:00 committed by GitHub
parent ffc0f460a0
commit 1a20b0a0c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 2727 additions and 488 deletions

View File

@ -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://<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'
@ -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

View File

@ -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

View File

@ -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
View File

@ -0,0 +1,27 @@
# SAML Registration with Identity Providers
This guide explains the settings youd 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

View File

@ -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.

View 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}
</>
);
}

View File

@ -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
View 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
View 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
View 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,
};
};

View File

@ -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",

View File

@ -1,10 +1,142 @@
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";
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
const providers: Provider[] = [
CredentialsProvider({
id: "credentials",
name: "Cal.com",
type: "credentials",
credentials: {
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
},
async authorize(credentials) {
if (!credentials) {
console.error(`For some reason credentials are missing`);
throw new Error(ErrorCode.InternalServerError);
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email.toLowerCase(),
},
});
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
if (user.identityProvider !== IdentityProvider.CAL) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
}
if (!user.password) {
throw new Error(ErrorCode.UserMissingPassword);
}
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectPassword);
}
if (user.twoFactorEnabled) {
if (!credentials.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = authenticator.check(credentials.totpCode, secret);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
return {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
};
},
}),
];
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: {
@ -16,85 +148,53 @@ export default NextAuth({
signOut: "/auth/logout",
error: "/auth/error", // Error code passed in query string as ?error=
},
providers: [
CredentialsProvider({
id: "credentials",
name: "Cal.com",
type: "credentials",
credentials: {
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
},
async authorize(credentials) {
if (!credentials) {
console.error(`For some reason credentials are missing`);
throw new Error(ErrorCode.InternalServerError);
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email.toLowerCase(),
},
});
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
if (!user.password) {
throw new Error(ErrorCode.UserMissingPassword);
}
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectPassword);
}
if (user.twoFactorEnabled) {
if (!credentials.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = authenticator.check(credentials.totpCode, secret);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
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,
name: user.name,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.username = user.username;
}
// 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;
},
},
});

View File

@ -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;

View File

@ -22,6 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
select: {
name: true,
identityProvider: true,
email: true,
},
});

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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 });
}
}

View File

@ -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,
},
});

View File

@ -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 });
}

View File

@ -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,
},
};
}

View File

@ -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(),
},

View 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,
},
};
};

View File

@ -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>
<ChangePasswordSection />
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} />
{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>
);

View File

@ -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} />

View File

@ -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");

View File

@ -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;

View 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
View 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]");
});

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- add the new value to the existing type
ALTER TYPE "IdentityProvider" ADD VALUE 'SAML';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "invitedTo" INTEGER;

View File

@ -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[]

View File

@ -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"
}

View File

@ -565,4 +565,4 @@
"error_required_field": "Este campo es requerido.",
"team_view_user_availability": "Ver disponibilidad de usuario",
"team_view_user_availability_disabled": "El usuario necesita aceptar la invitación para ver su disponibilidad"
}
}

View File

@ -525,4 +525,4 @@
"not_installed": "インストールされていません",
"error_password_mismatch": "パスワードが一致しません。",
"error_required_field": "この項目は必須です。"
}
}

View File

@ -575,4 +575,4 @@
"set_as_free": "Desactivar estado de ausência",
"user_away": "Este utilizador está ausente de momento.",
"user_away_description": "A pessoa com quem está a tentar fazer uma reserva está ausente, pelo que não está a aceitar novas reservas."
}
}

View File

@ -525,4 +525,4 @@
"not_installed": "Nu este instalat",
"error_password_mismatch": "Parolele nu se potrivesc.",
"error_required_field": "Acest câmp este obligatoriu."
}
}

View File

@ -561,4 +561,4 @@
"not_installed": "Не установлено",
"error_password_mismatch": "Пароли не совпадают.",
"error_required_field": "Это поле является обязательным."
}
}

View File

@ -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
View 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);

View File

@ -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()

View File

@ -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({
data: {
teamId: input.teamId,
userId: invitee.id,
role: input.role as MembershipRole,
// 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,
role: input.role as MembershipRole,
},
},
})
);
},
});
const token: string = randomBytes(32).toString("hex");

1944
yarn.lock

File diff suppressed because it is too large Load Diff