diff --git a/.env.example b/.env.example index 8e11bc3038..df8d9a8430 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,8 @@ SAML_DATABASE_URL= SAML_ADMINS= # NEXT_PUBLIC_HOSTED_CAL_FEATURES=1 NEXT_PUBLIC_HOSTED_CAL_FEATURES= +# For additional security set to a random secret and use that value as the client_secret during the OAuth 2.0 flow. +SAML_CLIENT_SECRET_VERIFIER= # If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line. # @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js diff --git a/apps/web/pages/api/auth/[...nextauth].tsx b/apps/web/pages/api/auth/[...nextauth].tsx index c58fbea361..b4cb08764f 100644 --- a/apps/web/pages/api/auth/[...nextauth].tsx +++ b/apps/web/pages/api/auth/[...nextauth].tsx @@ -17,7 +17,8 @@ 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 jackson from "@calcom/features/ee/sso/lib/jackson"; +import { hostedCal, isSAMLLoginEnabled, clientSecretVerifier } from "@calcom/features/ee/sso/lib/saml"; import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth"; import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; @@ -222,10 +223,65 @@ if (isSAMLLoginEnabled) { }, options: { clientId: "dummy", - clientSecret: "dummy", + clientSecret: clientSecretVerifier, }, allowDangerousEmailAccountLinking: true, }); + + // Idp initiated login + providers.push( + CredentialsProvider({ + id: "saml-idp", + name: "IdP Login", + credentials: { + code: {}, + }, + async authorize(credentials) { + if (!credentials) { + return null; + } + + const { code } = credentials; + + if (!code) { + return null; + } + + const { oauthController } = await jackson(); + + // Fetch access token + const { access_token } = await oauthController.token({ + code, + grant_type: "authorization_code", + redirect_uri: `${process.env.NEXTAUTH_URL}`, + client_id: "dummy", + client_secret: clientSecretVerifier, + }); + + if (!access_token) { + return null; + } + + // Fetch user info + const userInfo = await oauthController.userInfo(access_token); + + if (!userInfo) { + return null; + } + + const { id, firstName, lastName, email } = userInfo; + + return { + id: id as unknown as number, + firstName, + lastName, + email, + name: `${firstName} ${lastName}`.trim(), + email_verified: true, + }; + }, + }) + ); } if (true) { @@ -326,12 +382,18 @@ export default NextAuth({ ...token, }; }; - if (!user) { return await autoMergeIdentities(); } - - if (account && account.type === "credentials") { + if (!account) { + return token; + } + if (account.type === "credentials") { + // return token if credentials,saml-idp + if (account.provider === "saml-idp") { + return token; + } + // any other credentials, add user info return { ...token, id: user.id, @@ -346,11 +408,12 @@ export default NextAuth({ // 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 && account.type === "oauth" && account.provider && account.providerAccountId) { - let idP: IdentityProvider = IdentityProvider.GOOGLE; - if (account.provider === "saml") { - idP = IdentityProvider.SAML; + if (account.type === "oauth") { + if (!account.provider || !account.providerAccountId) { + return token; } + const idP = account.provider === "saml" ? IdentityProvider.SAML : IdentityProvider.GOOGLE; + const existingUser = await prisma.user.findFirst({ where: { AND: [ @@ -405,14 +468,18 @@ export default NextAuth({ if (account?.provider === "email") { return true; } + // 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; - } + // Only if provider is not saml-idp + if (account?.provider !== "saml-idp") { + if (account?.type === "credentials") { + return true; + } - if (account?.type !== "oauth") { - return false; + if (account?.type !== "oauth") { + return false; + } } if (!user.email) { @@ -425,7 +492,7 @@ export default NextAuth({ if (account?.provider) { let idP: IdentityProvider = IdentityProvider.GOOGLE; - if (account.provider === "saml") { + if (account.provider === "saml" || account.provider === "saml-idp") { idP = IdentityProvider.SAML; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -435,19 +502,18 @@ export default NextAuth({ if (!user.email_verified) { return "/auth/error?error=unverified-email"; } - // Only google oauth on this path - const provider = account.provider.toUpperCase() as IdentityProvider; + // Only google oauth on this path const existingUser = await prisma.user.findFirst({ include: { accounts: { where: { - provider: account.provider, + provider: idP, }, }, }, where: { - identityProvider: provider, + identityProvider: idP as IdentityProvider, identityProviderId: account.providerAccountId, }, }); @@ -569,6 +635,7 @@ export default NextAuth({ identityProviderId: String(user.id), }, }); + const linkAccountNewUserData = { ...account, userId: newUser.id }; await calcomAdapter.linkAccount(linkAccountNewUserData); diff --git a/apps/web/pages/api/auth/saml/callback.ts b/apps/web/pages/api/auth/saml/callback.ts index fd51d97314..45b993d87c 100644 --- a/apps/web/pages/api/auth/saml/callback.ts +++ b/apps/web/pages/api/auth/saml/callback.ts @@ -5,8 +5,12 @@ import { defaultHandler, defaultResponder } from "@calcom/lib/server"; 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); + + if (redirect_url) { + res.redirect(302, redirect_url); + } } export default defaultHandler({ diff --git a/apps/web/pages/auth/saml-idp.tsx b/apps/web/pages/auth/saml-idp.tsx new file mode 100644 index 0000000000..5834c74bbe --- /dev/null +++ b/apps/web/pages/auth/saml-idp.tsx @@ -0,0 +1,23 @@ +import { signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +// To handle the IdP initiated login flow callback +export default function Page() { + const router = useRouter(); + + useEffect(() => { + if (!router.isReady) { + return; + } + + const { code } = router.query; + + signIn("saml-idp", { + callbackUrl: "/", + code, + }); + }, []); + + return null; +} diff --git a/packages/features/ee/sso/lib/jackson.ts b/packages/features/ee/sso/lib/jackson.ts index d43491d920..777bcfb8d5 100644 --- a/packages/features/ee/sso/lib/jackson.ts +++ b/packages/features/ee/sso/lib/jackson.ts @@ -8,7 +8,7 @@ import type { import {WEBAPP_URL} from "@calcom/lib/constants"; -import {samlDatabaseUrl, samlAudience, samlPath, oidcPath} from "./saml"; +import { samlDatabaseUrl, samlAudience, samlPath, oidcPath, clientSecretVerifier } from "./saml"; // Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list const opts: JacksonOption = { @@ -22,6 +22,8 @@ const opts: JacksonOption = { url: samlDatabaseUrl, encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY, }, + idpEnabled: true, + clientSecretVerifier, }; let connectionController: IConnectionAPIController; diff --git a/packages/features/ee/sso/lib/saml.ts b/packages/features/ee/sso/lib/saml.ts index b715a78926..0f41ce4bc7 100644 --- a/packages/features/ee/sso/lib/saml.ts +++ b/packages/features/ee/sso/lib/saml.ts @@ -13,6 +13,7 @@ export const samlProductID = "Cal.com"; export const samlAudience = "https://saml.cal.com"; export const samlPath = "/api/auth/saml/callback"; export const oidcPath = "/api/auth/oidc"; +export const clientSecretVerifier = process.env.SAML_CLIENT_SECRET_VERIFIER || "dummy"; export const hostedCal = Boolean(HOSTED_CAL_FEATURES); export const tenantPrefix = "team-"; diff --git a/packages/trpc/server/routers/viewer/sso.tsx b/packages/trpc/server/routers/viewer/sso.tsx index 7e6451f60d..d4dfe3a44c 100644 --- a/packages/trpc/server/routers/viewer/sso.tsx +++ b/packages/trpc/server/routers/viewer/sso.tsx @@ -87,7 +87,7 @@ export const ssoRouter = router({ try { return await connectionController.createSAMLConnection({ encodedRawMetadata, - defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`, + defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/saml-idp`, redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]), tenant: teamId ? tenantPrefix + teamId : samlTenantID, product: samlProductID, diff --git a/turbo.json b/turbo.json index 81adc27fad..c553203821 100644 --- a/turbo.json +++ b/turbo.json @@ -248,6 +248,7 @@ "$SALESFORCE_CONSUMER_SECRET", "$SAML_ADMINS", "$SAML_DATABASE_URL", + "$SAML_CLIENT_SECRET_VERIFIER", "$SEND_FEEDBACK_EMAIL", "$SENTRY_DSN", "$NEXT_PUBLIC_SENTRY_DSN",