Reintroduce SAML SSO (#4938)
* wip reintroduce SAML SSO * Fix the imports * wip * Some tweaks * Fix the type * Reduce the textarea height * Cleanup * Fix the access issues * Make the SAML SSO active on the sidebar * Add SP's instructions * Remove the console.log * Add the condition to check SAML SSO is enabled * Replace SAML SSO with Single Sign-On * Update to SAML feature * Upgrade the @boxyhq/saml-jackson * Fix the SAML part and other cleanup * Tweaks to SAML SSO setup * Fix the type * Fix the import path * Remove samlLoginUrl * Import fixes * Simplifies endpoints Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
parent
41689ecc92
commit
759a89bb0c
|
@ -1,40 +0,0 @@
|
|||
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;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
|
@ -23,7 +23,7 @@
|
|||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "0.3.6",
|
||||
"@boxyhq/saml-jackson": "1.3.1",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/core": "*",
|
||||
|
|
|
@ -12,6 +12,7 @@ import path from "path";
|
|||
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
||||
import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||
|
@ -22,7 +23,6 @@ import prisma from "@calcom/prisma";
|
|||
import { ErrorCode, verifyPassword } from "@lib/auth";
|
||||
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
||||
import { randomString } from "@lib/random";
|
||||
import { hostedCal, 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";
|
||||
|
@ -135,7 +135,7 @@ if (isSAMLLoginEnabled) {
|
|||
version: "2.0",
|
||||
checks: ["pkce", "state"],
|
||||
authorization: {
|
||||
url: `${samlLoginUrl}/api/auth/saml/authorize`,
|
||||
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
|
||||
params: {
|
||||
scope: "",
|
||||
response_type: "code",
|
||||
|
@ -143,10 +143,10 @@ if (isSAMLLoginEnabled) {
|
|||
},
|
||||
},
|
||||
token: {
|
||||
url: `${samlLoginUrl}/api/auth/saml/token`,
|
||||
url: `${WEBAPP_URL}/api/auth/saml/token`,
|
||||
params: { grant_type: "authorization_code" },
|
||||
},
|
||||
userinfo: `${samlLoginUrl}/api/auth/saml/userinfo`,
|
||||
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id || "",
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
import { OAuthReqBody } from "@boxyhq/saml-jackson";
|
||||
import { OAuthReq } from "@boxyhq/saml-jackson";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
|
||||
import jackson from "@lib/jackson";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
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 { oauthController } = await jackson();
|
||||
const { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReqBody);
|
||||
res.redirect(302, redirect_url);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof HttpError) {
|
||||
console.error("authorize error:", err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
return res.status(statusCode).send(message);
|
||||
}
|
||||
return res.status(500).send("Unknown error");
|
||||
if (req.method !== "GET") {
|
||||
return res.status(400).send("Method not allowed");
|
||||
}
|
||||
|
||||
try {
|
||||
const { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReq);
|
||||
|
||||
return res.redirect(302, redirect_url as string);
|
||||
} catch (err) {
|
||||
const { message, statusCode = 500 } = err as HttpError;
|
||||
|
||||
return res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,14 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
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: unknown) {
|
||||
if (err instanceof HttpError) {
|
||||
console.error("callback error:", err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
return res.status(statusCode).send(message);
|
||||
}
|
||||
return res.status(500).send("Unknown error");
|
||||
}
|
||||
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { oauthController } = await jackson();
|
||||
const { redirect_url } = await oauthController.samlResponse(req.body);
|
||||
if (redirect_url) return res.redirect(302, redirect_url);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
|
||||
});
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { NextApiRequest } from "next";
|
||||
|
||||
import jackson from "@lib/jackson";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
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);
|
||||
}
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const { oauthController } = await jackson();
|
||||
return await oauthController.token(req.body);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
|
||||
});
|
||||
|
|
|
@ -1,53 +1,34 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { NextApiRequest } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import jackson from "@lib/jackson";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
const extractAuthToken = (req: NextApiRequest) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
const parts = (authHeader || "").split(" ");
|
||||
if (parts.length > 1) {
|
||||
return parts[1];
|
||||
}
|
||||
if (parts.length > 1) return parts[1];
|
||||
|
||||
return null;
|
||||
// check for query param
|
||||
let arr: string[] = [];
|
||||
const { access_token } = requestQuery.parse(req.query);
|
||||
arr = arr.concat(access_token);
|
||||
if (arr[0].length > 0) return arr[0];
|
||||
|
||||
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
||||
};
|
||||
|
||||
const requestQuery = z.object({
|
||||
access_token: z.string(),
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
const { access_token } = requestQuery.parse(req.query);
|
||||
arr = arr.concat(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 });
|
||||
}
|
||||
async function getHandler(req: NextApiRequest) {
|
||||
const { oauthController } = await jackson();
|
||||
const token = extractAuthToken(req);
|
||||
return await oauthController.userInfo(token);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { FaGoogle } from "react-icons/fa";
|
||||
|
||||
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
|
@ -18,7 +19,6 @@ import SAMLLogin from "@calcom/ui/v2/modules/auth/SAMLLogin";
|
|||
|
||||
import { ErrorCode, getSession } from "@lib/auth";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
|
||||
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@lib/saml";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
|
|
|
@ -5,12 +5,18 @@ import { useEffect } from "react";
|
|||
|
||||
import { getPremiumPlanPrice } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import {
|
||||
hostedCal,
|
||||
isSAMLLoginEnabled,
|
||||
samlProductID,
|
||||
samlTenantID,
|
||||
samlTenantProduct,
|
||||
} from "@calcom/features/ee/sso/lib/saml";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getSession } from "@lib/auth";
|
||||
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID, samlTenantProduct } from "@lib/saml";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/sso/page/user-sso-view";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/sso/page/teams-sso-view";
|
|
@ -4,6 +4,7 @@ import { useRouter } from "next/router";
|
|||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
@ -13,7 +14,6 @@ import { HeadSeo } from "@calcom/web/components/seo/head-seo";
|
|||
import { asStringOrNull } from "@calcom/web/lib/asStringOrNull";
|
||||
import { WEBAPP_URL } from "@calcom/web/lib/config/constants";
|
||||
import prisma from "@calcom/web/lib/prisma";
|
||||
import { isSAMLLoginEnabled } from "@calcom/web/lib/saml";
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/web/server/lib/constants";
|
||||
import { ssrInit } from "@calcom/web/server/lib/ssr";
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ test.describe("SAML tests", () => {
|
|||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML is enabled");
|
||||
// Try to go Security page
|
||||
await page.goto("/settings/security");
|
||||
await page.goto("/settings/security/sso");
|
||||
// It should redirect you to the event-types page
|
||||
// await page.waitForSelector("[data-testid=saml_config]");
|
||||
});
|
||||
|
|
|
@ -746,22 +746,11 @@
|
|||
"user_away_description": "The person you are trying to book has set themselves to away, and therefore is not accepting new bookings.",
|
||||
"meet_people_with_the_same_tokens": "Meet people with the same tokens",
|
||||
"only_book_people_and_allow": "Only book and allow bookings from people who share the same tokens, DAOs, or NFTs.",
|
||||
"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",
|
||||
"you_will_need_to_generate": "You will need to generate an access token from your old scheduling tool.",
|
||||
"import": "Import",
|
||||
"import_from": "Import from",
|
||||
|
@ -1241,7 +1230,6 @@
|
|||
"theme_dark": "Dark",
|
||||
"theme_system": "System default",
|
||||
"add_a_team": "Add a team",
|
||||
"saml_config": "SAML Config",
|
||||
"add_webhook_description": "Receive meeting data in real-time when something happens in Cal.com",
|
||||
"triggers_when": "Triggers when",
|
||||
"test_webhook": "Please ping test before creating.",
|
||||
|
@ -1315,5 +1303,25 @@
|
|||
"no_available_slots": "No Available slots",
|
||||
"time_available": "Time available",
|
||||
"install_new_calendar_app": "Install new calendar app",
|
||||
"make_phone_number_required": "Make phone number required for booking event"
|
||||
"make_phone_number_required": "Make phone number required for booking event",
|
||||
"dont_have_permission": "You don't have permission to access this resource.",
|
||||
"saml_config": "Single Sign-On",
|
||||
"saml_description": "Allow team members to login using an Identity Provider",
|
||||
"saml_config_deleted_successfully": "SAML configuration deleted successfully",
|
||||
"saml_config_updated_successfully": "SAML configuration updated successfully",
|
||||
"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_email_required": "Please enter an email so we can find your SAML Identity Provider",
|
||||
"saml_sp_title": "Service Provider Details",
|
||||
"saml_sp_description": "Your Identity Provider (IdP) will ask you for the following details to complete the SAML application configuration.",
|
||||
"saml_sp_acs_url": "ACS URL",
|
||||
"saml_sp_entity_id": "SP Entity ID",
|
||||
"saml_sp_acs_url_copied": "ACS URL copied!",
|
||||
"saml_sp_entity_id_copied": "SP Entity ID copied!",
|
||||
"saml_btn_configure": "Configure"
|
||||
}
|
||||
|
|
|
@ -1,164 +0,0 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Badge from "@calcom/ui/Badge";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import LicenseRequired from "./LicenseRequired";
|
||||
|
||||
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 }]);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
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;
|
||||
|
||||
// track Google logins. Without personal data/payload
|
||||
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
|
||||
|
||||
mutation.mutate({
|
||||
encodedRawMetadata: Buffer.from(rawMetadata).toString("base64"),
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSAMLConfigHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||
event.preventDefault();
|
||||
|
||||
deleteMutation.mutate({
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
{isSAMLLoginEnabled ? (
|
||||
<LicenseRequired>
|
||||
<hr className="mt-8" />
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">
|
||||
{t("saml_configuration")}
|
||||
<Badge className="ml-2 text-xs" variant={samlConfig ? "success" : "gray"}>
|
||||
{samlConfig ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
{samlConfig ? (
|
||||
<>
|
||||
<Badge className="ml-2 text-xs" 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}
|
||||
placeholder={t("saml_configuration_placeholder")}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end py-8">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</form>
|
||||
</LicenseRequired>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/ee/common/components/v2/LicenseRequired";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||
import { showToast } from "@calcom/ui/v2";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import { Form, TextArea } from "@calcom/ui/v2/core/form/fields";
|
||||
|
||||
interface TeamSSOValues {
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export default function ConfigDialogForm({
|
||||
teamId,
|
||||
handleClose,
|
||||
}: {
|
||||
teamId: number | null;
|
||||
handleClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const form = useForm<TeamSSOValues>();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.saml.update", {
|
||||
async onSuccess() {
|
||||
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
|
||||
await utils.invalidateQueries(["viewer.saml.get"]);
|
||||
showToast(t("saml_config_updated_successfully"), "success");
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
teamId,
|
||||
encodedRawMetadata: Buffer.from(values.metadata).toString("base64"),
|
||||
});
|
||||
}}>
|
||||
<div className="mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">
|
||||
{t("saml_configuration")}
|
||||
</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("saml_configuration_description")}</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="metadata"
|
||||
render={({ field: { value } }) => (
|
||||
<div>
|
||||
<TextArea
|
||||
data-testid="saml_config"
|
||||
name="metadata"
|
||||
value={value}
|
||||
className="h-40"
|
||||
required={true}
|
||||
placeholder={t("saml_configuration_placeholder")}
|
||||
onChange={(e) => {
|
||||
form.setValue("metadata", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import ConfigDialogForm from "@calcom/features/ee/sso/components/ConfigDialogForm";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import { ClipboardCopyIcon } from "@calcom/ui/Icon";
|
||||
import { Button, showToast, Label } from "@calcom/ui/v2";
|
||||
import Badge from "@calcom/ui/v2/core/Badge";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger, DialogContent } from "@calcom/ui/v2/core/Dialog";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import SkeletonLoader from "@calcom/ui/v2/core/apps/SkeletonLoader";
|
||||
|
||||
export default function SAMLConfiguration({ teamId }: { teamId: number | null }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [configModal, setConfigModal] = useState(false);
|
||||
|
||||
const { data: connection, isLoading } = trpc.useQuery(["viewer.saml.get", { teamId }], {
|
||||
onError: (err) => {
|
||||
setHasError(true);
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setHasError(false);
|
||||
setErrorMessage("");
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.useMutation("viewer.saml.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.saml.get"]);
|
||||
showToast(t("saml_config_deleted_successfully"), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConnection = () => {
|
||||
mutation.mutate({
|
||||
teamId,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("saml_config")} description={t("saml_description")} />
|
||||
<Alert severity="warning" message={t(errorMessage)} className="mb-4 " />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("saml_config")} description={t("saml_description")} />
|
||||
<LicenseRequired>
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="mb-3">
|
||||
{connection && connection.provider ? (
|
||||
<Badge variant="green" bold>
|
||||
SAML SSO enabled via {connection.provider}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="gray" bold>
|
||||
{t("saml_not_configured_yet")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon={Icon.FiDatabase}
|
||||
onClick={() => {
|
||||
setConfigModal(true);
|
||||
}}>
|
||||
{t("saml_btn_configure")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Provider Details */}
|
||||
{connection && connection.provider && (
|
||||
<>
|
||||
<hr className="border-1 my-8 border-gray-200" />
|
||||
<div className="mb-3 text-base font-semibold">{t("saml_sp_title")}</div>
|
||||
<p className="mt-3 text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
|
||||
{t("saml_sp_description")}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>{t("saml_sp_acs_url")}</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 font-mono text-gray-800">
|
||||
{connection.acsUrl}
|
||||
</code>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(connection.acsUrl);
|
||||
showToast(t("saml_sp_acs_url_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="px-4 text-base">
|
||||
<ClipboardCopyIcon className="h-5 w-5 text-neutral-100" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>{t("saml_sp_entity_id")}</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 font-mono text-gray-800">
|
||||
{connection.entityId}
|
||||
</code>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(connection.entityId);
|
||||
showToast(t("saml_sp_entity_id_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="px-4 text-base">
|
||||
<ClipboardCopyIcon className="h-5 w-5 text-neutral-100" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Danger Zone and Delete Confirmation */}
|
||||
{connection && connection.provider && (
|
||||
<>
|
||||
<hr className="border-1 my-8 border-gray-200" />
|
||||
<div className="mb-3 text-base font-semibold">{t("danger_zone")}</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Icon.FiTrash2}>
|
||||
{t("delete_saml_configuration")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_saml_configuration")}
|
||||
confirmBtnText={t("confirm_delete_saml_configuration")}
|
||||
onConfirm={deleteConnection}>
|
||||
{t("delete_saml_configuration_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add/Update SAML Connection */}
|
||||
<Dialog open={configModal} onOpenChange={setConfigModal}>
|
||||
<DialogContent type="creation" useOwnActionButtons>
|
||||
<ConfigDialogForm handleClose={() => setConfigModal(false)} teamId={teamId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</LicenseRequired>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import jackson, {
|
||||
IConnectionAPIController,
|
||||
IOAuthController,
|
||||
JacksonOption,
|
||||
ISPSAMLConfig,
|
||||
} from "@boxyhq/saml-jackson";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { samlDatabaseUrl, samlAudience, samlPath } from "./saml";
|
||||
|
||||
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
|
||||
const opts: JacksonOption = {
|
||||
externalUrl: WEBAPP_URL,
|
||||
samlPath,
|
||||
samlAudience,
|
||||
db: {
|
||||
engine: "sql",
|
||||
type: "postgres",
|
||||
url: samlDatabaseUrl,
|
||||
encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
let connectionController: IConnectionAPIController;
|
||||
let oauthController: IOAuthController;
|
||||
let samlSPConfig: ISPSAMLConfig;
|
||||
|
||||
const g = global as any;
|
||||
|
||||
export default async function init() {
|
||||
if (!g.connectionController || !g.oauthController) {
|
||||
const ret = await jackson(opts);
|
||||
|
||||
connectionController = ret.connectionAPIController;
|
||||
oauthController = ret.oauthController;
|
||||
samlSPConfig = ret.spConfig;
|
||||
|
||||
g.connectionController = connectionController;
|
||||
g.oauthController = oauthController;
|
||||
g.samlSPConfig = samlSPConfig;
|
||||
} else {
|
||||
connectionController = g.connectionController;
|
||||
oauthController = g.oauthController;
|
||||
samlSPConfig = g.samlSPConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
connectionController,
|
||||
oauthController,
|
||||
samlSPConfig,
|
||||
};
|
||||
}
|
|
@ -1,20 +1,21 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaClient, UserPlan } from "@prisma/client";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import { WEBAPP_URL } from "./constants";
|
||||
|
||||
export const samlDatabaseUrl = process.env.SAML_DATABASE_URL || "";
|
||||
export const samlLoginUrl = WEBAPP_URL;
|
||||
|
||||
export const isSAMLLoginEnabled = samlDatabaseUrl.length > 0;
|
||||
|
||||
export const samlTenantID = "Cal.com";
|
||||
export const samlProductID = "Cal.com";
|
||||
export const samlAudience = "https://saml.cal.com";
|
||||
export const samlPath = "/api/auth/saml/callback";
|
||||
|
||||
export const hostedCal = Boolean(HOSTED_CAL_FEATURES);
|
||||
export const tenantPrefix = "team-";
|
||||
|
||||
const samlAdmins = (process.env.SAML_ADMINS || "").split(",");
|
||||
export const hostedCal = WEBAPP_URL === "https://app.cal.com";
|
||||
export const tenantPrefix = "team-";
|
||||
|
||||
export const isSAMLAdmin = (email: string) => {
|
||||
for (const admin of samlAdmins) {
|
||||
|
@ -57,3 +58,49 @@ export const samlTenantProduct = async (prisma: PrismaClient, email: string) =>
|
|||
product: samlProductID,
|
||||
};
|
||||
};
|
||||
|
||||
export const canAccess = async (
|
||||
user: { id: number; plan: UserPlan; email: string },
|
||||
teamId: number | null
|
||||
) => {
|
||||
const { id: userId, plan, email } = user;
|
||||
|
||||
if (!isSAMLLoginEnabled) {
|
||||
return {
|
||||
message: "To enable this feature, add value for `SAML_DATABASE_URL` and `SAML_ADMINS` to your `.env`",
|
||||
access: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Hosted
|
||||
if (HOSTED_CAL_FEATURES) {
|
||||
if (teamId === null || !(await isTeamAdmin(userId, teamId))) {
|
||||
return {
|
||||
message: "dont_have_permission",
|
||||
access: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (plan != UserPlan.PRO) {
|
||||
return {
|
||||
message: "app_upgrade_description",
|
||||
access: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Self-hosted
|
||||
if (!HOSTED_CAL_FEATURES) {
|
||||
if (!isSAMLAdmin(email)) {
|
||||
return {
|
||||
message: "dont_have_permission",
|
||||
access: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: "success",
|
||||
access: true,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import SkeletonLoader from "@calcom/ui/v2/core/apps/SkeletonLoader";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
import SAMLConfiguration from "../components/SAMLConfiguration";
|
||||
|
||||
const SAMLSSO = () => {
|
||||
const router = useRouter();
|
||||
|
||||
if (!HOSTED_CAL_FEATURES) {
|
||||
router.push("/404");
|
||||
}
|
||||
|
||||
const teamId = Number(router.query.id);
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId }], {
|
||||
onError: () => {
|
||||
router.push("/settings");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
router.push("/404");
|
||||
return;
|
||||
}
|
||||
|
||||
return <SAMLConfiguration teamId={teamId} />;
|
||||
};
|
||||
|
||||
SAMLSSO.getLayout = getLayout;
|
||||
|
||||
export default SAMLSSO;
|
|
@ -0,0 +1,22 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
import SAMLConfiguration from "../components/SAMLConfiguration";
|
||||
|
||||
const SAMLSSO = () => {
|
||||
const router = useRouter();
|
||||
|
||||
if (HOSTED_CAL_FEATURES) {
|
||||
router.push("/404");
|
||||
}
|
||||
|
||||
const teamId = null;
|
||||
|
||||
return <SAMLConfiguration teamId={teamId} />;
|
||||
};
|
||||
|
||||
SAMLSSO.getLayout = getLayout;
|
||||
|
||||
export default SAMLSSO;
|
|
@ -1,40 +0,0 @@
|
|||
import jackson, { IAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
|
||||
|
||||
import { WEBAPP_URL } from "./constants";
|
||||
import { samlDatabaseUrl } from "./saml";
|
||||
|
||||
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
|
||||
const opts: JacksonOption = {
|
||||
externalUrl: WEBAPP_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;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -14,25 +14,15 @@ import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/Cale
|
|||
import { DailyLocationType } from "@calcom/core/location";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
|
||||
import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import jackson from "@calcom/lib/jackson";
|
||||
import {
|
||||
hostedCal,
|
||||
isSAMLAdmin,
|
||||
isSAMLLoginEnabled,
|
||||
samlProductID,
|
||||
samlTenantID,
|
||||
samlTenantProduct,
|
||||
tenantPrefix,
|
||||
} from "@calcom/lib/saml";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import {
|
||||
deleteWebUser as syncServicesDeleteWebUser,
|
||||
|
@ -50,6 +40,7 @@ import { authRouter } from "./viewer/auth";
|
|||
import { availabilityRouter } from "./viewer/availability";
|
||||
import { bookingsRouter } from "./viewer/bookings";
|
||||
import { eventTypesRouter } from "./viewer/eventTypes";
|
||||
import { samlRouter } from "./viewer/saml";
|
||||
import { slotsRouter } from "./viewer/slots";
|
||||
import { viewerTeamsRouter } from "./viewer/teams";
|
||||
import { webhookRouter } from "./viewer/webhook";
|
||||
|
@ -1043,99 +1034,6 @@ 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({
|
||||
encodedRawMetadata: z.string(),
|
||||
teamId: z.union([z.number(), z.null(), z.undefined()]),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { encodedRawMetadata, teamId } = input;
|
||||
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const { apiController } = await jackson();
|
||||
|
||||
try {
|
||||
return await apiController.config({
|
||||
encodedRawMetadata,
|
||||
defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`,
|
||||
redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_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({ ctx, input }) {
|
||||
const { teamId } = input;
|
||||
if (teamId && !(await isTeamOwner(ctx.user?.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
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" });
|
||||
}
|
||||
},
|
||||
})
|
||||
.mutation("submitFeedback", {
|
||||
input: z.object({
|
||||
rating: z.string(),
|
||||
|
@ -1532,6 +1430,7 @@ export const viewerRouter = createRouter()
|
|||
.merge("apiKeys.", apiKeysRouter)
|
||||
.merge("slots.", slotsRouter)
|
||||
.merge("workflows.", workflowsRouter)
|
||||
.merge("saml.", samlRouter)
|
||||
|
||||
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
|
||||
// After that there would just one merge call here for all the apps.
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { samlProductID, samlTenantID, tenantPrefix, canAccess } from "@calcom/features/ee/sso/lib/saml";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createProtectedRouter } from "../../createRouter";
|
||||
|
||||
export const samlRouter = createProtectedRouter()
|
||||
// Retrieve SAML Connection
|
||||
.query("get", {
|
||||
input: z.object({
|
||||
teamId: z.union([z.number(), z.null()]),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { connectionController, samlSPConfig } = await jackson();
|
||||
|
||||
const { teamId } = input;
|
||||
|
||||
const { message, access } = await canAccess(ctx.user, teamId);
|
||||
|
||||
if (!access) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
// Retrieve the SP SAML Config
|
||||
const SPConfig = samlSPConfig.get();
|
||||
|
||||
const response = {
|
||||
provider: "",
|
||||
acsUrl: SPConfig.acsUrl,
|
||||
entityId: SPConfig.entityId,
|
||||
};
|
||||
|
||||
try {
|
||||
const connections = await connectionController.getConnections({
|
||||
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
|
||||
product: samlProductID,
|
||||
});
|
||||
|
||||
if (connections.length > 0 && "idpMetadata" in connections[0]) {
|
||||
response["provider"] = connections[0].idpMetadata.provider;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error getting SAML config", err);
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SAML Connection failed." });
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
})
|
||||
|
||||
// Update the SAML Connection
|
||||
.mutation("update", {
|
||||
input: z.object({
|
||||
encodedRawMetadata: z.string(),
|
||||
teamId: z.union([z.number(), z.null()]),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { connectionController } = await jackson();
|
||||
|
||||
const { encodedRawMetadata, teamId } = input;
|
||||
|
||||
const { message, access } = await canAccess(ctx.user, teamId);
|
||||
|
||||
if (!access) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await connectionController.createSAMLConnection({
|
||||
encodedRawMetadata,
|
||||
defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`,
|
||||
redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]),
|
||||
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
|
||||
product: samlProductID,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error updating SAML connection", err);
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Updating SAML Connection failed." });
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Delete the SAML Connection
|
||||
.mutation("delete", {
|
||||
input: z.object({
|
||||
teamId: z.union([z.number(), z.null()]),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { connectionController } = await jackson();
|
||||
|
||||
const { teamId } = input;
|
||||
|
||||
const { message, access } = await canAccess(ctx.user, teamId);
|
||||
|
||||
if (!access) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await connectionController.deleteConnections({
|
||||
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
|
||||
product: samlProductID,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error deleting SAML connection", err);
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." });
|
||||
}
|
||||
},
|
||||
});
|
|
@ -6,6 +6,7 @@ import React, { ComponentProps, useEffect, useState } from "react";
|
|||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
@ -39,7 +40,6 @@ const tabs: VerticalTabItemProps[] = [
|
|||
href: "/settings/security",
|
||||
icon: Icon.FiKey,
|
||||
children: [
|
||||
//
|
||||
{ name: "password", href: "/settings/security/password" },
|
||||
{ name: "2fa_auth", href: "/settings/security/two-factor-auth" },
|
||||
],
|
||||
|
@ -81,6 +81,13 @@ const tabs: VerticalTabItemProps[] = [
|
|||
},
|
||||
];
|
||||
|
||||
tabs.find((tab) => {
|
||||
// Add "SAML SSO" to the tab
|
||||
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
|
||||
tab.children?.push({ name: "saml_config", href: "/settings/security/sso" });
|
||||
}
|
||||
});
|
||||
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
|
||||
|
@ -100,7 +107,6 @@ const SettingsSidebarContainer = ({ className = "" }) => {
|
|||
const tabsWithPermissions = useTabs();
|
||||
const [teamMenuState, setTeamMenuState] =
|
||||
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { data: teams } = trpc.useQuery(["viewer.teams.list"]);
|
||||
|
||||
|
@ -236,13 +242,14 @@ const SettingsSidebarContainer = ({ className = "" }) => {
|
|||
textClassNames="px-3 text-gray-900 font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{/* TODO: Implement saml configuration page */}
|
||||
{/* <VerticalTabItem
|
||||
name={t("saml_config")}
|
||||
href={`${WEBAPP_URL}/settings/teams/${team.id}/samlConfig`}
|
||||
textClassNames="px-3 text-gray-900 font-medium text-sm"
|
||||
disableChevron
|
||||
/> */}
|
||||
{HOSTED_CAL_FEATURES && (
|
||||
<VerticalTabItem
|
||||
name={t("saml_config")}
|
||||
href={`/settings/teams/${team.id}/sso`}
|
||||
textClassNames="px-3 text-gray-900 font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
|
|
Loading…
Reference in New Issue
Block a user