Add Idp-Initiated SSO (#6781)

* wip idp enabled login

* add route to handle callback from IdP

* update the new provider

* cleanup

* fix the type

* add suggested changes

* make the suggested changes

* use client secret verifier

* Make [...nextauth] a little easier to read

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
pull/7572/head
Kiran K 2023-03-08 03:01:39 +05:30 committed by GitHub
parent 7eecc311e0
commit 2fa83bd512
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 122 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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