Add CLI support for SAML SSO

This commit is contained in:
Tuan Dang
2023-07-24 01:53:56 +07:00
parent 1419371588
commit 446edb6ed9
8 changed files with 104 additions and 50 deletions

View File

@@ -18,10 +18,15 @@ import {
router.get(
"/redirect/google",
authLimiter,
passport.authenticate("google", {
scope: ["profile", "email"],
session: false,
})
(req, res, next) => {
passport.authenticate("google", {
scope: ["profile", "email"],
session: false,
...(req.query.callback_port ? {
state: req.query.callback_port as string
} : {})
})(req, res, next);
}
);
router.get(
@@ -36,16 +41,22 @@ router.get(
router.get(
"/redirect/saml2/:ssoIdentifier",
authLimiter,
passport.authenticate("saml", {
failureRedirect: "/login/fail"
})
(req, res, next) => {
const options = {
failureRedirect: "/",
additionalParams: {
RelayState: req.query.callback_port ?? ""
},
};
passport.authenticate("saml", options)(req, res, next);
}
);
router.post("/saml2/:ssoIdentifier",
passport.authenticate("saml", {
failureRedirect: "/login/provider/error",
failureFlash: true,
session: false
session: false
}),
ssoController.redirectSSO
);

View File

@@ -114,7 +114,10 @@ const initializePassport = async () => {
firstName,
lastName,
authProvider: user.authProvider,
isUserCompleted
isUserCompleted,
...(req.query.state ? {
callbackPort: req.query.state as string
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getJwtProviderAuthSecret(),
@@ -153,7 +156,6 @@ const initializePassport = async () => {
},
},
async (req: any, profile: any, done: any) => {
if (!req.ssoConfig.isActive) return done(InternalServerError());
const organization = await Organization.findById(req.ssoConfig.organization);
@@ -199,7 +201,10 @@ const initializePassport = async () => {
lastName,
organizationName: organization?.name,
authProvider: user.authProvider,
isUserCompleted
isUserCompleted,
...(req.body.RelayState ? {
callbackPort: req.body.RelayState as string
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getJwtProviderAuthSecret(),

View File

@@ -18,6 +18,8 @@ export const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const queryParams = new URLSearchParams(window.location.search)
useEffect(() => {
// TODO(akhilmhdh): workspace will be controlled by a workspace context
const redirectToDashboard = async () => {
@@ -30,7 +32,6 @@ export const Login = () => {
const userDetails = await fetchUserDetails()
// send details back to client
const queryParams = new URLSearchParams(window.location.search)
if (queryParams && queryParams.get("callback_port")) {
const callbackPort = queryParams.get("callback_port")
@@ -67,6 +68,7 @@ export const Login = () => {
email={email}
password={password}
providerAuthToken={undefined}
callbackPort={queryParams.get("callback_port")}
/>
);
case 2:

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router"
import jwt_decode from "jwt-decode";
import {
@@ -14,25 +13,20 @@ type Props = {
export const LoginSSO = ({ providerAuthToken }: Props) => {
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
const router = useRouter();
const {
email,
isUserCompleted,
callbackPort
} = jwt_decode(providerAuthToken) as any;
useEffect(() => {
if (!isUserCompleted) {
router.push(`/signup/sso?token=${encodeURIComponent(providerAuthToken)}`);
}
if (isUserCompleted) {
setStep(1);
}
}, []);
const renderView = () => {
// TODO: consider adding a complete account step here that's uniquely for SSO
switch (step) {
case 0:
return (
@@ -42,6 +36,7 @@ export const LoginSSO = ({ providerAuthToken }: Props) => {
return (
<PasswordStep
providerAuthToken={providerAuthToken}
callbackPort={callbackPort}
email={email}
password={password}
setPassword={setPassword}
@@ -52,6 +47,7 @@ export const LoginSSO = ({ providerAuthToken }: Props) => {
return (
<MFAStep
providerAuthToken={providerAuthToken}
callbackPort={callbackPort}
email={email}
password={password}
/>

View File

@@ -34,6 +34,8 @@ export const InitialStep = ({
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const queryParams = new URLSearchParams(window.location.search);
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
try {
@@ -42,7 +44,6 @@ export const InitialStep = ({
}
setIsLoading(true);
const queryParams = new URLSearchParams(window.location.search)
if (queryParams && queryParams.get("callback_port")) {
const callbackPort = queryParams.get("callback_port")
@@ -164,7 +165,9 @@ export const InitialStep = ({
colorSchema="primary"
variant="solid"
onClick={() => {
window.open("/api/v1/sso/redirect/google");
const callbackPort = queryParams.get("callback_port");
window.open(`/api/v1/sso/redirect/google${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
window.close();
}}
leftIcon={<FontAwesomeIcon icon={faGoogle} className="mr-1" />}

View File

@@ -36,6 +36,7 @@ type Props = {
email: string;
password: string;
providerAuthToken?: string;
callbackPort?: string | null;
}
interface VerifyMfaTokenError {
@@ -53,7 +54,8 @@ interface VerifyMfaTokenError {
export const MFAStep = ({
email,
password,
providerAuthToken
providerAuthToken,
callbackPort
}: Props) => {
const { createNotification } = useNotificationContext();
const router = useRouter();
@@ -77,9 +79,7 @@ export const MFAStep = ({
}
setIsLoading(true);
const queryParams = new URLSearchParams(window.location.search)
if (queryParams && queryParams.get("callback_port")){
const callbackPort = queryParams.get("callback_port")
if (callbackPort){
// attemptCliLogin
const isCliLoginSuccessful = await attemptCliLoginMfa({

View File

@@ -2,14 +2,17 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router"
import axios from "axios"
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
import attemptLogin from "@app/components/utilities/attemptLogin";
import { Button, Input } from "@app/components/v2";
import getOrganizations from "@app/pages/api/organization/getOrgs";
type Props = {
providerAuthToken: string;
callbackPort?: string;
email: string;
password: string;
setPassword: (password: string) => void;
@@ -18,6 +21,7 @@ type Props = {
export const PasswordStep = ({
providerAuthToken,
callbackPort,
email,
password,
setPassword,
@@ -31,34 +35,64 @@ export const PasswordStep = ({
const handleLogin = async () => {
try {
setIsLoading(true);
const loginAttempt = await attemptLogin({
email,
password,
providerAuthToken,
});
if (callbackPort) {
// attemptCliLogin
const isCliLoginSuccessful = await attemptCliLogin({
email,
password,
providerAuthToken
})
if (loginAttempt && loginAttempt.success) {
// case: login was successful
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
if (loginAttempt.mfaEnabled) {
// TODO: deal with MFA
// case: login requires MFA step
setIsLoading(false);
setStep(2);
return;
if (isCliLoginSuccessful.mfaEnabled) {
// case: login requires MFA step
setStep(2);
setIsLoading(false);
return;
}
// case: login was successful
const cliUrl = `http://localhost:${callbackPort}`
// send request to server endpoint
const instance = axios.create()
await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse })
// cli page
router.push("/cli-redirect");
// on success, router.push to cli Login Successful page
}
// case: login does not require MFA step
const userOrgs = await getOrganizations();
const userOrg = userOrgs[0]._id;
setIsLoading(false);
createNotification({
text: "Successfully logged in",
type: "success"
} else {
const loginAttempt = await attemptLogin({
email,
password,
providerAuthToken,
});
router.push(`/org/${userOrg?._id}/overview`);
}
if (loginAttempt && loginAttempt.success) {
// case: login was successful
if (loginAttempt.mfaEnabled) {
// TODO: deal with MFA
// case: login requires MFA step
setIsLoading(false);
setStep(2);
return;
}
// case: login does not require MFA step
const userOrgs = await getOrganizations();
const userOrg = userOrgs[0]._id;
setIsLoading(false);
createNotification({
text: "Successfully logged in",
type: "success"
});
router.push(`/org/${userOrg?._id}/overview`);
}
}
} catch (err) {
setIsLoading(false);
createNotification({

View File

@@ -13,6 +13,8 @@ export const SAMLSSOStep = ({
const [ssoIdentifier, setSSOIdentifier] = useState("");
const { t } = useTranslation();
const queryParams = new URLSearchParams(window.location.search);
return (
<div className="mx-auto w-full max-w-md md:px-6">
<p className="mx-auto mb-6 flex w-max justify-center text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-8">
@@ -37,7 +39,8 @@ export const SAMLSSOStep = ({
colorSchema="primary"
variant="outline_bg"
onClick={() => {
window.open(`/api/v1/sso/redirect/saml2/${ssoIdentifier}`);
const callbackPort = queryParams.get("callback_port");
window.open(`/api/v1/sso/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
window.close();
}}
isFullWidth