diff --git a/Clients/src/application/config/routes.tsx b/Clients/src/application/config/routes.tsx index 499d4a196e..650c5bdd5d 100644 --- a/Clients/src/application/config/routes.tsx +++ b/Clients/src/application/config/routes.tsx @@ -14,6 +14,7 @@ import ForgotPassword from "../../presentation/pages/Authentication/ForgotPasswo import ResetPassword from "../../presentation/pages/Authentication/ResetPassword"; import SetNewPassword from "../../presentation/pages/Authentication/SetNewPassword"; import ResetPasswordContinue from "../../presentation/pages/Authentication/ResetPasswordContinue"; +import MicrosoftCallback from "../../presentation/pages/Authentication/MicrosoftCallback"; import FileManager from "../../presentation/pages/FileManager"; import Reporting from "../../presentation/pages/Reporting"; import VWHome from "../../presentation/pages/Home/1.0Home"; @@ -144,7 +145,12 @@ export const createRoutes = ( path="/reset-password-continue" element={} />, -// } />, + } + />, + // } />, } />, } />, // Style Guide - Development only diff --git a/Clients/src/application/constants/permissions.ts b/Clients/src/application/constants/permissions.ts index 5722356c1c..bcb48bd9bd 100644 --- a/Clients/src/application/constants/permissions.ts +++ b/Clients/src/application/constants/permissions.ts @@ -67,6 +67,10 @@ const allowedRoles = { deleteDataset: ["Admin", "Editor"], manageApiKeys: ["Admin"], }, + sso: { + view: ["Admin"], + manage: ["Admin"], + }, }; export default allowedRoles; diff --git a/Clients/src/application/repository/organization.repository.ts b/Clients/src/application/repository/organization.repository.ts index 9f48df25ad..174a9e5c57 100644 --- a/Clients/src/application/repository/organization.repository.ts +++ b/Clients/src/application/repository/organization.repository.ts @@ -81,3 +81,19 @@ export async function checkOrganizationExists(): Promise { throw error; } } + +/** + * Retrieves all organizations in the system (public endpoint for login). + * + * @returns {Promise} List of all organizations. + * @throws Will throw an error if the request fails. + */ +export async function getAllOrganizations(): Promise { + try { + const response = await apiServices.get("/organizations"); + const data = response.data as { data?: any[] }; + return data?.data ?? []; + } catch (error) { + throw error; + } +} diff --git a/Clients/src/application/repository/ssoConfig.repository.ts b/Clients/src/application/repository/ssoConfig.repository.ts new file mode 100644 index 0000000000..cc006eaae7 --- /dev/null +++ b/Clients/src/application/repository/ssoConfig.repository.ts @@ -0,0 +1,88 @@ +import { GetRequestParams, RequestParams } from "../../domain/interfaces/i.requestParams"; +import { apiServices } from "../../infrastructure/api/networkServices"; + +/** + * Retrieves SSO configuration for an organization. + * + * @param {GetRequestParams} params - The parameters for the request. + * @returns {Promise} The SSO configuration data retrieved from the API. + * @throws Will throw an error if the request fails. + */ +export async function GetSsoConfig({ + routeUrl, + signal, + responseType = "json", +}: GetRequestParams): Promise { + try { + const response = await apiServices.get(routeUrl, { + signal, + responseType, + }); + return response; + } catch (error) { + throw error; + } +} + +/** + * Updates SSO configuration for an organization. + * + * @param {RequestParams} params - The parameters for updating the SSO configuration. + * @returns {Promise} A promise that resolves to the updated SSO configuration data. + * @throws Will throw an error if the update operation fails. + */ +export async function UpdateSsoConfig({ + routeUrl, + body, +}: RequestParams): Promise { + try { + const response = await apiServices.put(routeUrl, body); + return response.data; + } catch (error) { + throw error; + } +} + +/** + * Enables or disables SSO for an organization. + * + * @param {RequestParams} params - The parameters for enabling/disabling SSO. + * @returns {Promise} A promise that resolves to the response data. + * @throws Will throw an error if the operation fails. + */ +export async function ToggleSsoStatus({ + routeUrl, + body, +}: RequestParams): Promise { + try { + const response = await apiServices.put(routeUrl, body); + return response.data; + } catch (error) { + throw error; + } +} + +/** + * Check SSO status for a specific organization (public endpoint for login). + * + * @param {number} organizationId - The ID of the organization. + * @param {string} provider - The SSO provider (e.g., 'AzureAD'). + * @returns {Promise} A promise that resolves to SSO status data. + * @throws Will throw an error if the operation fails. + */ +export async function CheckSsoStatusByOrgId({ + organizationId, + provider = 'AzureAD', +}: { + organizationId: number; + provider?: string; +}): Promise { + try { + const response = await apiServices.get( + `ssoConfig/check-status?organizationId=${organizationId}&provider=${provider}` + ); + return response.data; + } catch (error) { + throw error; + } +} diff --git a/Clients/src/application/repository/user.repository.ts b/Clients/src/application/repository/user.repository.ts index 15fe4c0be8..cdbe6e3274 100644 --- a/Clients/src/application/repository/user.repository.ts +++ b/Clients/src/application/repository/user.repository.ts @@ -215,3 +215,19 @@ export async function deleteUserProfilePhoto(userId: number | string): Promise(`/users/${userId}/profile-photo`); return response; } + +export async function loginUserWithMicrosoft({ + code, + organizationId, +}: { + code: string; + organizationId: number; +}): Promise { + try { + const response = await apiServices.post(`/users/login-microsoft`, { code, organizationId }); + return response; + } catch (error) { + console.error("Error logging in with Microsoft:", error); + throw error; + } +} \ No newline at end of file diff --git a/Clients/src/domain/types/User.ts b/Clients/src/domain/types/User.ts index bd2ac2565a..11920f0d6d 100644 --- a/Clients/src/domain/types/User.ts +++ b/Clients/src/domain/types/User.ts @@ -12,6 +12,8 @@ export type User = { organization_id?: number; //organization association pwd_set?: boolean; //password set flag (compatibility) data?: any; //compatibility property for API responses + sso_provider?: string | null; + sso_user_id?: string | null; } export interface ApiResponse { diff --git a/Clients/src/presentation/assets/icons/microsoft-icon.svg b/Clients/src/presentation/assets/icons/microsoft-icon.svg new file mode 100644 index 0000000000..8a7cb18b85 --- /dev/null +++ b/Clients/src/presentation/assets/icons/microsoft-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Clients/src/presentation/components/Inputs/SelectField/index.tsx b/Clients/src/presentation/components/Inputs/SelectField/index.tsx new file mode 100644 index 0000000000..cd71324607 --- /dev/null +++ b/Clients/src/presentation/components/Inputs/SelectField/index.tsx @@ -0,0 +1,168 @@ +/** + * A customizable select field component that matches the Field component's styling. + * Provides consistent styling with text inputs for organization selection. + */ + +import { + Select, + MenuItem, + Stack, + Typography, + useTheme, + SelectChangeEvent, +} from "@mui/material"; +import { getInputStyles } from "../../../utils/inputStyles"; + +interface SelectFieldProps { + id?: string; + label?: string; + isRequired?: boolean; + isOptional?: boolean; + optionalLabel?: string; + placeholder?: string; + value: string | number; + onChange: (event: SelectChangeEvent) => void; + options: Array<{ id: number | string; name: string }>; + error?: string; + disabled?: boolean; + width?: string | number; + sx?: any; + loading?: boolean; +} + +const SelectField = ({ + id, + label, + isRequired, + isOptional, + optionalLabel, + placeholder, + value, + onChange, + options, + error, + disabled, + width, + sx, + loading = false, +}: SelectFieldProps) => { + const theme = useTheme(); + + const rootSx = sx; + + return ( + + {label && ( + + {label} + {isRequired ? ( + + * + + ) : ( + "" + )} + {isOptional ? ( + + {optionalLabel || "(optional)"} + + ) : ( + "" + )} + + )} + + {error && ( + + {error} + + )} + + ); +}; + +export default SelectField; diff --git a/Clients/src/presentation/components/MicrosoftSignIn/index.tsx b/Clients/src/presentation/components/MicrosoftSignIn/index.tsx new file mode 100644 index 0000000000..0e1a73ecb7 --- /dev/null +++ b/Clients/src/presentation/components/MicrosoftSignIn/index.tsx @@ -0,0 +1,139 @@ +import { Button, useTheme } from "@mui/material" +import { useState, useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { setAuthToken, setExpiration } from "../../../application/redux/auth/authSlice"; +import { ReactComponent as MicrosoftIcon } from "../../assets/icons/microsoft-icon.svg"; + +interface MicrosoftSignInProps { + isSubmitting: boolean; + setIsSubmitting: (isSubmitting: boolean) => void; + tenantId?: string; + clientId?: string; + organizationId?: number; + text?: string; +} + +export const MicrosoftSignIn: React.FC = ({ + isSubmitting, + setIsSubmitting, + tenantId, + clientId, + organizationId, + text = "Sign in with Microsoft" +}) => { + const theme = useTheme(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const [_, setAlert] = useState<{ + variant: "success" | "info" | "warning" | "error"; + title?: string; + body: string; + } | null>(null); + + // Listen for messages from the popup window + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Verify the message is from our origin + if (event.origin !== window.location.origin) return; + + if (event.data.type === 'MICROSOFT_AUTH_SUCCESS') { + dispatch(setAuthToken(event.data.token)); + dispatch(setExpiration(event.data.expirationDate)); + localStorage.setItem('root_version', __APP_VERSION__); + setIsSubmitting(false); + navigate("/"); + } else if (event.data.type === 'MICROSOFT_AUTH_ERROR') { + setIsSubmitting(false); + setAlert({ + variant: "error", + body: event.data.error || "SSO authentication failed", + }); + setTimeout(() => setAlert(null), 3000); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [dispatch, navigate, setIsSubmitting]); + + // Handle Microsoft Sign-in + const handleMicrosoftSignIn = async () => { + if (!tenantId || !clientId) { + setAlert({ + variant: "error", + body: "Microsoft Sign-In is not configured. Please contact your administrator.", + }); + setTimeout(() => setAlert(null), 3000); + return; + } + + try { + setIsSubmitting(true); + + // Construct Microsoft OAuth URL + const redirectUri = `${window.location.origin}/auth/microsoft/callback`; + const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?` + + `client_id=${clientId}&` + + `response_type=code&` + + `redirect_uri=${encodeURIComponent(redirectUri)}&` + + `scope=openid profile email&` + + `response_mode=query`; + + // Open Microsoft login in new tab + window.open(authUrl, '_blank'); + setIsSubmitting(false); + } catch (error: any) { + setIsSubmitting(false); + setAlert({ + variant: "error", + body: "Failed to initiate Microsoft Sign-In. Please try again.", + }); + setTimeout(() => setAlert(null), 3000); + } + }; + + return ( + + ) +} diff --git a/Clients/src/presentation/pages/Authentication/Login/index.tsx b/Clients/src/presentation/pages/Authentication/Login/index.tsx index 00e2c66828..b913684b26 100644 --- a/Clients/src/presentation/pages/Authentication/Login/index.tsx +++ b/Clients/src/presentation/pages/Authentication/Login/index.tsx @@ -1,8 +1,9 @@ -import { Button, Stack, Typography, useTheme, Box } from "@mui/material"; -import React, { Suspense, useState } from "react"; +import { Button, Stack, Typography, useTheme, Box, Divider } from "@mui/material"; +import React, { Suspense, useEffect, useState } from "react"; import { ReactComponent as Background } from "../../../assets/imgs/background-grid.svg"; import Checkbox from "../../../components/Inputs/Checkbox"; import Field from "../../../components/Inputs/Field"; +import SelectField from "../../../components/Inputs/SelectField"; import singleTheme from "../../../themes/v1SingleTheme"; import { useNavigate } from "react-router-dom"; import { logEngine } from "../../../../application/tools/log.engine"; @@ -13,6 +14,9 @@ import Alert from "../../../components/Alert"; import { ENV_VARs } from "../../../../../env.vars"; import { useIsMultiTenant } from "../../../../application/hooks/useIsMultiTenant"; import { loginUser } from "../../../../application/repository/user.repository"; +import { MicrosoftSignIn } from "../../../components/MicrosoftSignIn"; +import { CheckSsoStatusByOrgId } from "../../../../application/repository/ssoConfig.repository"; +import { getAllOrganizations } from "../../../../application/repository/organization.repository"; // Animated loading component specifically for login const LoginLoadingOverlay: React.FC = () => { @@ -130,6 +134,76 @@ const Login: React.FC = () => { body: string; } | null>(null); + // Organization selection state + const [organizations, setOrganizations] = useState([]); + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [loadingOrgs, setLoadingOrgs] = useState(true); + + // SSO configuration state + const [ssoConfig, setSsoConfig] = useState<{ + tenantId?: string; + clientId?: string; + isEnabled?: boolean; + }>({}); + + // Fetch all organizations on mount + useEffect(() => { + const fetchOrganizations = async () => { + try { + setLoadingOrgs(true); + const orgs = await getAllOrganizations(); + setOrganizations(orgs); + } catch (error) { + console.error('Failed to fetch organizations:', error); + setOrganizations([]); + setAlert({ + variant: "error", + body: "Failed to load organizations. Please refresh the page." + }); + } finally { + setLoadingOrgs(false); + } + }; + + fetchOrganizations(); + }, []); + + // Fetch SSO configuration when organization is selected + useEffect(() => { + const fetchSsoConfig = async () => { + if (!selectedOrgId) { + setSsoConfig({}); + return; + } + + try { + const response = await CheckSsoStatusByOrgId({ + organizationId: selectedOrgId, + provider: 'AzureAD', + }); + + if (response?.isEnabled && response?.hasConfig) { + setSsoConfig({ + tenantId: response.tenantId, + clientId: response.clientId, + isEnabled: response.isEnabled, + }); + // Store organization ID for Microsoft callback + sessionStorage.setItem('sso_organization_id', selectedOrgId.toString()); + } else { + setSsoConfig({}); + sessionStorage.removeItem('sso_organization_id'); + } + } catch (error) { + console.error('Failed to fetch SSO config:', error); + setSsoConfig({}); + sessionStorage.removeItem('sso_organization_id'); + } + }; + + fetchSsoConfig(); + }, [selectedOrgId]); + // Handle changes in input fields const handleChange = (prop: keyof FormValues) => @@ -140,6 +214,13 @@ const Login: React.FC = () => { // Handle form submission const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + + if (!selectedOrgId && organizations.length > 0) { + setAlert({ variant: "error", body: "Please select an organization" }); + setTimeout(() => setAlert(null), 5000); + return; + } + setIsSubmitting(true); await loginUser({ @@ -286,7 +367,7 @@ const Login: React.FC = () => { {loginText} - + { )} + + {/* Divider with "or" */} + + + + or + + + + {/* Organization Selection - Always show at bottom */} + setSelectedOrgId(Number(e.target.value))} + options={organizations.map(org => ({ id: org.id, name: org.name }))} + disabled={loadingOrgs || organizations.length === 0} + loading={loadingOrgs} + /> + + {/* Microsoft SSO Button - Always show when organization is selected */} + {selectedOrgId && ( + + )} diff --git a/Clients/src/presentation/pages/Authentication/MicrosoftCallback/index.tsx b/Clients/src/presentation/pages/Authentication/MicrosoftCallback/index.tsx new file mode 100644 index 0000000000..d0b9aeef0c --- /dev/null +++ b/Clients/src/presentation/pages/Authentication/MicrosoftCallback/index.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from "react"; +import { Box, CircularProgress, Typography } from "@mui/material"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { setAuthToken, setExpiration } from "../../../../application/redux/auth/authSlice"; +import { loginUserWithMicrosoft } from "../../../../application/repository/user.repository"; + +const MicrosoftCallback: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const [error, setError] = useState(""); + + useEffect(() => { + const handleCallback = async () => { + const code = searchParams.get("code"); + const errorParam = searchParams.get("error"); + const errorDescription = searchParams.get("error_description"); + + // Handle error from Microsoft + if (errorParam) { + setError(errorDescription || "Authentication failed"); + setTimeout(() => { + navigate("/login", { state: { error: errorDescription || "SSO authentication failed" } }); + }, 3000); + return; + } + + // Handle missing code + if (!code) { + setError("No authorization code received"); + setTimeout(() => { + navigate("/login", { state: { error: "Invalid SSO callback" } }); + }, 3000); + return; + } + + try { + // Retrieve organization ID from session storage (set during organization selection on login page) + const orgIdFromSession = sessionStorage.getItem('sso_organization_id'); + const organizationId = orgIdFromSession ? parseInt(orgIdFromSession, 10) : 1; // Default to 1 if not found + + const response = await loginUserWithMicrosoft({ code, organizationId }); + + if (response.status === 202 || response.status === 200) { + const token = response.data.data.token; + + // Always remember Microsoft sign-in for 30 days + const expirationDate = Date.now() + 30 * 24 * 60 * 60 * 1000; + + // If opened in popup, send message to parent and close + if (window.opener) { + window.opener.postMessage( + { + type: 'MICROSOFT_AUTH_SUCCESS', + token, + expirationDate + }, + window.location.origin + ); + window.close(); + } else { + // If not a popup, use regular flow + dispatch(setAuthToken(token)); + dispatch(setExpiration(expirationDate)); + localStorage.setItem('root_version', __APP_VERSION__); + navigate("/"); + } + } else { + throw new Error("Authentication failed"); + } + } catch (err) { + setError("Authentication failed. Please try again."); + // If opened in popup, notify parent of error + if (window.opener) { + window.opener.postMessage( + { + type: 'MICROSOFT_AUTH_ERROR', + error: 'SSO authentication failed' + }, + window.location.origin + ); + setTimeout(() => window.close(), 3000); + } else { + setTimeout(() => { + navigate("/login", { state: { error: "SSO authentication failed" } }); + }, 3000); + } + } + }; + + handleCallback(); + }, [searchParams, navigate]); + + return ( + + {error ? ( + <> + + {error} + + + Redirecting to login... + + + ) : ( + <> + + Completing sign in... + + )} + + ); +}; + +export default MicrosoftCallback; diff --git a/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/SsoConfigTab.tsx b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/SsoConfigTab.tsx new file mode 100644 index 0000000000..13608a6bac --- /dev/null +++ b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/SsoConfigTab.tsx @@ -0,0 +1,407 @@ +import React, { useState, useCallback, useEffect } from "react"; +import { + Box, + Stack, + Typography, + useTheme, + CircularProgress, +} from "@mui/material"; +import Field from "../../../components/Inputs/Field"; +import Alert from "../../../components/Alert"; +import Button from "../../../components/Button"; +import Select from "../../../components/Inputs/Select"; +import { useAuth } from "../../../../application/hooks/useAuth"; +import { GetSsoConfig, UpdateSsoConfig, ToggleSsoStatus } from "../../../../application/repository/ssoConfig.repository"; + +// State interface for SSO Configuration (MVP) +interface SsoConfig { + tenantId: string; + clientId: string; + clientSecret: string; + cloudEnvironment: string; + isEnabled: boolean; + authMethodPolicy: 'sso_only' | 'password_only' | 'both'; +} + +// Validation errors interface +interface ValidationErrors { + tenantId?: string; + clientId?: string; + clientSecret?: string; +} + +// Cloud environment options +const cloudEnvironments = [ + { _id: "AzurePublic", name: "Azure Public Cloud" }, + { _id: "AzureGovernment", name: "Azure Government" } +]; + +// Authentication method policy options +// const authMethodPolicies = [ +// { _id: "both", name: "Allow both SSO and password authentication" }, +// { _id: "sso_only", name: "Require SSO authentication only" }, +// { _id: "password_only", name: "Allow password authentication only" } +// ]; + +const SsoConfigTab: React.FC = () => { + const { organizationId } = useAuth(); + const [config, setConfig] = useState({ + tenantId: "", + clientId: "", + clientSecret: "", + cloudEnvironment: "AzurePublic", + isEnabled: false, + authMethodPolicy: "both", + }); + + const [errors, setErrors] = useState({}); + const [isSaving, setIsSaving] = useState(false); + const [isEnabling, setIsEnabling] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const theme = useTheme(); + + // Fetch SSO config on mount + useEffect(() => { + const fetchSsoConfig = async () => { + if (!organizationId) { + setIsLoading(false); + return; + } + + try { + const response = await GetSsoConfig({ + routeUrl: `ssoConfig?organizationId=${organizationId}&provider=AzureAD`, + }); + + if (response?.data) { + // Map backend response to frontend state + setConfig({ + tenantId: response.data.config_data?.tenant_id || "", + clientId: response.data.config_data?.client_id || "", + clientSecret: response.data.config_data?.client_secret || "", + cloudEnvironment: response.data.config_data?.cloud_environment || "AzurePublic", + isEnabled: response.data.is_enabled || false, + authMethodPolicy: "both", + }); + } + // If error or no config found, keep default empty state + } catch (error) { + console.error('Failed to fetch SSO config:', error); + // Keep default empty state on error + } finally { + setIsLoading(false); + } + }; + + fetchSsoConfig(); + }, [organizationId]); + + // Validation functions + const validateUUID = (value: string): boolean => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(value); + }; + + const validateField = useCallback((field: keyof ValidationErrors, value: string) => { + const newErrors = { ...errors }; + + switch (field) { + case 'tenantId': + if (!value) { + newErrors.tenantId = "Tenant ID is required"; + } else if (!validateUUID(value)) { + newErrors.tenantId = "Please enter a valid UUID format"; + } else { + delete newErrors.tenantId; + } + break; + case 'clientId': + if (!value) { + newErrors.clientId = "Client ID is required"; + } else if (!validateUUID(value)) { + newErrors.clientId = "Please enter a valid UUID format"; + } else { + delete newErrors.clientId; + } + break; + case 'clientSecret': + if (!value) { + newErrors.clientSecret = "Client Secret is required"; + } else if (value.length < 10) { + newErrors.clientSecret = "Client Secret must be at least 10 characters"; + } else { + delete newErrors.clientSecret; + } + break; + } + + setErrors(newErrors); + }, [errors]); + + const handleFieldChange = (field: keyof SsoConfig) => ( + event: React.ChangeEvent + ) => { + const value = event.target.value; + setConfig(prev => ({ ...prev, [field]: value })); + + // Real-time validation for specific fields + if (field === 'tenantId' || field === 'clientId' || field === 'clientSecret') { + validateField(field, value); + } + }; + + + const handleEnableSSO = async () => { + setIsEnabling(true); + try { + if (Object.keys(errors).length === 0 && organizationId) { + const endpoint = config.isEnabled ? 'disable' : 'enable'; + + await ToggleSsoStatus({ + routeUrl: `ssoConfig/${endpoint}?organizationId=${organizationId}&provider=AzureAD`, + body: {}, + }); + + // Toggle SSO enabled state locally + setConfig(prev => ({ ...prev, isEnabled: !prev.isEnabled })); + console.log(`SSO ${endpoint}d successfully`); + } + } catch (error) { + console.error('Error toggling SSO status:', error); + } finally { + setIsEnabling(false); + } + }; + + + const handleSelectChange = (field: keyof SsoConfig) => ( + event: any + ) => { + setConfig(prev => ({ ...prev, [field]: event.target.value })); + }; + + + const handleSave = async () => { + setIsSaving(true); + + // Validate all required fields first + validateField('tenantId', config.tenantId); + validateField('clientId', config.clientId); + validateField('clientSecret', config.clientSecret); + + // Check if there are any validation errors + const hasErrors = !config.tenantId || !config.clientId || !config.clientSecret || Object.keys(errors).length > 0; + + if (hasErrors) { + setIsSaving(false); + return; + } + + try { + if (organizationId) { + await UpdateSsoConfig({ + routeUrl: `ssoConfig?organizationId=${organizationId}&provider=AzureAD`, + body: { + client_id: config.clientId, + client_secret: config.clientSecret, + tenant_id: config.tenantId, + cloud_environment: config.cloudEnvironment, + }, + }); + + // Success - could show a toast notification here + console.log('SSO configuration saved successfully'); + } + } catch (error) { + console.error('Error saving SSO config:', error); + // Could show error toast here + } finally { + setIsSaving(false); + } + }; + + // Card-like container styles - matching AI Trust Center spacing + const cardStyles = { + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1.5px solid ${theme.palette.divider}`, + padding: theme.spacing(5, 6), // 40px top/bottom, 48px left/right - same as AI Trust Center + boxShadow: 'none', + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + + {/* Setup Guide Alert */} + + + + + {/* Simplified SSO Configuration Card */} + + + EntraID SSO configuration + + + + + + + + Found in Azure Portal > Microsoft Entra ID > Overview > Tenant ID + + + + + + + Found in Azure Portal > App registrations > [Your App] > Application (client) ID + + + + + + + + + + + option._id} + sx={{ width: '100%' }} + /> + + Controls which authentication methods are allowed for users in this organization + + */} + + + + {/* Save/Cancel Buttons for Configuration */} + + + + + + + + + {/* Enable SSO Card */} + + + Enable EntraID SSO + + + + + Enable SSO authentication for this organization. Configuration must be saved before enabling. + + + + {config.isEnabled && config.authMethodPolicy === 'sso_only' && ( + + )} + {config.isEnabled && config.authMethodPolicy === 'both' && ( + + )} + {config.authMethodPolicy === 'password_only' && ( + + )} + + + + + + + ); +}; + +export default SsoConfigTab; diff --git a/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/index.tsx b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/index.tsx new file mode 100644 index 0000000000..6410f22b80 --- /dev/null +++ b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/index.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Box } from "@mui/material"; +import SsoConfigTab from "./SsoConfigTab"; + +const EntraIdConfig: React.FC = () => { + return ( + + + + ); +}; + +export default EntraIdConfig; diff --git a/Clients/src/presentation/pages/SettingsPage/Team/index.tsx b/Clients/src/presentation/pages/SettingsPage/Team/index.tsx index 32ea301863..c6572dd42d 100644 --- a/Clients/src/presentation/pages/SettingsPage/Team/index.tsx +++ b/Clients/src/presentation/pages/SettingsPage/Team/index.tsx @@ -23,6 +23,7 @@ import { SelectChangeEvent, TablePagination, TableFooter, + Tooltip, } from "@mui/material"; import { UserPlus as GroupsIcon, @@ -46,6 +47,7 @@ import { import useUsers from "../../../../application/hooks/useUsers"; import { useAuth } from "../../../../application/hooks/useAuth"; import { UserModel } from "../../../../domain/models/Common/user/user.model"; +import { GetSsoConfig } from "../../../../application/repository/ssoConfig.repository"; interface AlertState { variant: "success" | "info" | "warning" | "error"; @@ -137,6 +139,26 @@ const TeamManagement: React.FC = (): JSX.Element => { const [rowsPerPage, setRowsPerPage] = useState(5); // Rows per page const [inviteUserModalOpen, setInviteUserModalOpen] = useState(false); + const [isSsoEnabled, setIsSsoEnabled] = useState(false); + const { organizationId } = useAuth(); + + // Check if SSO is enabled + useEffect(() => { + const checkSsoStatus = async () => { + try { + const response = await GetSsoConfig({ + routeUrl: `ssoConfig?organizationId=${organizationId}&provider=AzureAD`, + }); + const ssoConfig = response?.data || response; + setIsSsoEnabled(ssoConfig?.is_enabled === true); + } catch (error) { + console.error("Failed to check SSO status:", error); + setIsSsoEnabled(false); + } + }; + + checkSsoStatus(); + }, []); const handleUpdateRole = useCallback( async (memberId: string, newRole: string) => { @@ -459,17 +481,25 @@ const TeamManagement: React.FC = (): JSX.Element => { )} - } - onClick={() => inviteTeamMember()} - /> + + + } + onClick={() => inviteTeamMember()} + /> + + @@ -628,7 +658,7 @@ const TeamManagement: React.FC = (): JSX.Element => { padding: "0", }, }} - disabled={member.id === userId} + disabled={member.id === userId || isSsoEnabled} > {roles.map((role) => ( { : "inherit", }} > - handleDeleteClick(member.id)} - disableRipple - disabled={member.id === userId} + - - + + handleDeleteClick(member.id)} + disableRipple + disabled={member.id === userId || isSsoEnabled} + > + + + + )) diff --git a/Clients/src/presentation/pages/SettingsPage/index.tsx b/Clients/src/presentation/pages/SettingsPage/index.tsx index c0971cf668..a2b9c4cfb2 100644 --- a/Clients/src/presentation/pages/SettingsPage/index.tsx +++ b/Clients/src/presentation/pages/SettingsPage/index.tsx @@ -12,17 +12,21 @@ import Preferences from "./Preferences/index"; import allowedRoles from "../../../application/constants/permissions"; import { useAuth } from "../../../application/hooks/useAuth"; import ApiKeys from "./ApiKeys"; +import EntraIdConfig from "./EntraIdConfig"; import HelperIcon from "../../components/HelperIcon"; import PageHeader from "../../components/Layout/PageHeader"; import TipBox from "../../components/TipBox"; import TabBar from "../../components/TabBar"; export default function ProfilePage() { - const { userRoleName } = useAuth(); + // const authorizedActiveTabs = ["profile", "password", "team", "organization", "sso"]; + const { userRoleName, userId } = useAuth(); const location = useLocation(); const navigate = useNavigate(); const isTeamManagementDisabled = !allowedRoles.projects.editTeamMembers.includes(userRoleName); + const isSsoTabDisabled = !allowedRoles.sso.view.includes(userRoleName); + const [isPasswordTabDisabled, setIsPasswordTabDisabled] = useState(false); const isApiKeysDisabled = !allowedRoles.apiKeys?.view?.includes(userRoleName); const { tab } = useParams<{ tab?: string }>(); @@ -37,6 +41,7 @@ export default function ProfilePage() { "team", "organization", "apikeys", + "entraid", ]; return tabs; }, []); @@ -51,9 +56,36 @@ export default function ProfilePage() { } }, [tab, validTabs, navigate]); + // Check if user is SSO-authenticated and disable password tab + useEffect(() => { + const checkSsoStatus = async () => { + if (!userId) return; + + try { + const { getUserById } = await import("../../../application/repository/user.repository"); + const userData = await getUserById({ userId }); + const actualUserData = userData?.data || userData; + + // If user has SSO provider and SSO user ID, disable password tab + if (actualUserData?.sso_provider && actualUserData?.sso_user_id) { + setIsPasswordTabDisabled(true); + // If currently on password tab, redirect to profile + if (activeTab === "password") { + setActiveTab("profile"); + } + } + } catch (error) { + console.error("Failed to check SSO status:", error); + } + }; + + checkSsoStatus(); + }, [userId, activeTab]); + // Handle navigation state from command palette useEffect(() => { if (location.state?.activeTab) { + const validTabs = ['profile', 'password', 'team', 'organization', 'entraid']; const requestedTab = location.state.activeTab; // Check if requested tab is valid and user has permission to access it @@ -111,6 +143,7 @@ export default function ProfilePage() { label: "Password", value: "password", icon: "Lock", + disabled: isPasswordTabDisabled, }, { label: "Team", @@ -134,6 +167,12 @@ export default function ProfilePage() { icon: "Key", disabled: isApiKeysDisabled, }, + { + label: "EntraID SSO", + value: "entraid", + icon: "Shield", + disabled: isSsoTabDisabled, + } ]} activeTab={activeTab} onChange={handleTabChange} @@ -162,6 +201,10 @@ export default function ProfilePage() { + + + + ); diff --git a/Servers/controllers/ssoConfig.ctrl.ts b/Servers/controllers/ssoConfig.ctrl.ts new file mode 100644 index 0000000000..3d5f760854 --- /dev/null +++ b/Servers/controllers/ssoConfig.ctrl.ts @@ -0,0 +1,117 @@ +import { Request, Response } from "express"; +import { SSOProvider } from "../domain.layer/interfaces/i.ssoConfig"; +import { disableSSOQuery, enableSSOQuery, getSSOConfigQuery, saveSSOConfigQuery } from "../utils/ssoConfig.utils"; +import { getTenantHash } from "../tools/getTenantHash"; + +export const getSSOConfigForOrg = async (req: Request, res: Response) => { + try { + const organizationId = parseInt(req.query.organizationId as string, 10); + const provider = req.query.provider as SSOProvider; + const tenant = getTenantHash(organizationId); + + const ssoConfig = await getSSOConfigQuery(provider, tenant); + if (!ssoConfig) { + return res.status(404).json({ error: "SSO configuration not found" }); + } + return res.status(200).json({ + ...ssoConfig, + config_data: { + ...(ssoConfig.config_data as { + client_id: string; + client_secret: string; + tenant_id: string; + }), + client_secret: "********", + }, + }); + } catch (error) { + console.log(error); + return res.status(500).json({ error: "Internal server error" }); + } +} + +export const saveSSOConfig = async (req: Request, res: Response) => { + try { + const organizationId = parseInt(req.query.organizationId as string, 10); + const provider = req.query.provider as SSOProvider; + const ssoConfigData = req.body; + const tenant = getTenantHash(organizationId); + + const result = await saveSSOConfigQuery(provider, ssoConfigData, tenant); + return res.status(201).json({ + ...result, + config_data: { + ...(result.config_data as { + client_id: string; + client_secret: string; + tenant_id: string; + }), + client_secret: "********", + }, + }); + } catch (error) { + console.log(error); + return res.status(500).json({ error: "Internal server error" }); + } +} + +export const enableSSO = async (req: Request, res: Response) => { + try { + const organizationId = parseInt(req.query.organizationId as string, 10); + const provider = req.query.provider as SSOProvider; + const tenant = getTenantHash(organizationId); + + await enableSSOQuery(provider, tenant); + return res.json({ message: "SSO enabled successfully" }); + } catch (error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +export const disableSSO = async (req: Request, res: Response) => { + try { + const organizationId = parseInt(req.query.organizationId as string, 10); + const provider = req.query.provider as SSOProvider; + const tenant = getTenantHash(organizationId); + + await disableSSOQuery(provider, tenant); + return res.json({ message: "SSO disabled successfully" }); + } catch (error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * Check SSO status for an organization (public endpoint for login page) + * Returns SSO configuration status for a specific organization + */ +export const checkSSOStatusByOrgId = async (req: Request, res: Response) => { + try { + const organizationId = parseInt(req.query.organizationId as string, 10); + const provider = req.query.provider as SSOProvider; + + if (!organizationId || isNaN(organizationId)) { + return res.status(400).json({ error: "Invalid organization ID" }); + } + + const tenant = getTenantHash(organizationId); + const ssoConfig = await getSSOConfigQuery(provider, tenant); + + if (!ssoConfig) { + return res.status(200).json({ + isEnabled: false, + hasConfig: false, + }); + } + + return res.status(200).json({ + isEnabled: ssoConfig.is_enabled || false, + hasConfig: true, + tenantId: (ssoConfig.config_data as any)?.tenant_id, + clientId: (ssoConfig.config_data as any)?.client_id, + }); + } catch (error) { + console.error('Error checking SSO status:', error); + return res.status(500).json({ error: "Internal server error" }); + } +} diff --git a/Servers/controllers/user.ctrl.ts b/Servers/controllers/user.ctrl.ts index 5210236f1d..ed67477f5c 100644 --- a/Servers/controllers/user.ctrl.ts +++ b/Servers/controllers/user.ctrl.ts @@ -62,11 +62,18 @@ import { Transaction } from "sequelize"; import logger, { logStructured } from "../utils/logger/fileLogger"; import { logEvent } from "../utils/logger/dbLogger"; import { generateUserTokens } from "../utils/auth.utils"; +import { ConfidentialClientApplication } from '@azure/msal-node'; +import { getAzureADConfigQuery } from "../utils/ssoConfig.utils"; +import Jwt from "jsonwebtoken"; import { sendSlackNotification } from "../services/slack/slackNotificationService"; import { SlackNotificationRoutingType } from "../domain.layer/enums/slack.enum"; import { getRoleByIdQuery } from "../utils/role.utils"; import { uploadFile } from "../utils/fileUpload.utils"; +const roleMap = new Map( + [["Admin", 1], ["Reviewer", 2], ["Editor", 3], ["Auditor", 4]] +); + /** * Retrieves all users within the authenticated user's organization * @@ -257,9 +264,9 @@ async function createNewUserWrapper( name, surname, email, - password, roleId, - organizationId + organizationId, + password, ); // Validate user data before saving @@ -518,7 +525,7 @@ async function loginUser(req: Request, res: Response): Promise { } catch (modelError) { passwordIsMatched = await bcrypt.compare( password, - userData.password_hash + userData.password_hash! ); } @@ -580,6 +587,92 @@ async function loginUser(req: Request, res: Response): Promise { } } +async function loginUserWithMicrosoft(req: Request, res: Response): Promise { + const transaction = await sequelize.transaction(); + const { code, organizationId } = req.body; + + logStructured('processing', `attempting Microsoft SSO login with code`, 'loginUserWithMicrosoft', 'user.ctrl.ts'); + logger.debug(`🔐 Microsoft SSO login attempt`); + + try { + if (!code) { + return res.status(400).json(STATUS_CODE[400]('Authorization code is required')); + } + + const azureADConfig = await getAzureADConfigQuery(req.tenantId!, transaction); + + const cca = new ConfidentialClientApplication({ + auth: { + clientId: azureADConfig.client_id, + authority: `https://login.microsoftonline.com/${azureADConfig.tenant_id}`, + clientSecret: Jwt.verify(azureADConfig.client_secret, process.env.SSO_SECRET as string) as string, + }, + }); + + // Exchange code for Microsoft access token + const response = await cca.acquireTokenByCode({ + code, + scopes: ['openid', 'profile', 'email', 'User.Read'], + redirectUri: "http://localhost:5173/auth/microsoft/callback", + }); + if (!response) { + logStructured('error', `failed to acquire token from Microsoft`, 'loginUserWithMicrosoft', 'user.ctrl.ts'); + return res.status(401).json(STATUS_CODE[401]('Failed to acquire token from Microsoft')); + } + + // Get user info from Microsoft Graph API + const userInfoResponse = await fetch('https://graph.microsoft.com/v1.0/me', { + headers: { + 'Authorization': `Bearer ${response.accessToken}`, + }, + }); + const userInfo = await userInfoResponse.json(); + // ROLE + const userRole = ((response.idTokenClaims as { [key: string]: any })?.roles || ['Editor'])[0] as string; + + // Find or create user in database + let user = await getUserByEmailQuery(userInfo.mail || userInfo.userPrincipalName, transaction); + if (!user) { + // Create user model with automatic password hashing + const userModel = await UserModel.createNewUser( + userInfo.givenName || userInfo.displayName, + userInfo.surname || userInfo.givenName || userInfo.displayName, + userInfo.mail || userInfo.userPrincipalName, + roleMap.get(userRole)!, + organizationId, + null, 'AzureAD', userInfo.id + ); + await userModel.validateUserData(); + const createdUser = await createNewUserQuery(userModel, transaction); + user = { ...createdUser.toSafeJSON(), role_name: userRole }; + } else if (user.role_name !== userRole) { + user.role_name = userRole; + await updateUserByIdQuery(user.id!, { role_id: roleMap.get(userRole)! }, transaction); + } + + // Generate JWT tokens + // Generate JWT tokens (access + refresh) + const { accessToken } = generateUserTokens({ + id: user!.id!, + email: user!.email, + roleName: user!.role_name as string, + organizationId: user!.organization_id as number, + }, res); + + // Placeholder response - implement actual Microsoft OAuth flow + logStructured('successful', `Microsoft SSO login successful for ${user!.email}`, 'loginUserWithMicrosoft', 'user.ctrl.ts'); + return res.status(202).json( + STATUS_CODE[202]({ + token: accessToken, + }) + ); + } catch (error) { + logStructured('error', `unexpected error during Microsoft SSO login`, 'loginUserWithMicrosoft', 'user.ctrl.ts'); + logger.error('❌ Error in loginUserWithMicrosoft:', error); + return res.status(500).json(STATUS_CODE[500]((error as Error).message)); + } +} + /** * Generates a new access token using a valid refresh token * @@ -711,9 +804,9 @@ async function resetPassword(req: Request, res: Response) { _user.name, _user.surname, _user.email, - _user.password_hash, _user.role_id, - _user.organization_id! + _user.organization_id!, + _user.password_hash, ); if (user) { @@ -721,7 +814,7 @@ async function resetPassword(req: Request, res: Response) { const updatedUser = (await resetPasswordQuery( email, - user.password_hash, + user.password_hash!, transaction )) as UserModel; @@ -1235,7 +1328,7 @@ async function ChangePassword(req: Request, res: Response) { const updatedUser = (await resetPasswordQuery( user.email, - user.password_hash, + user.password_hash!, transaction )) as UserModel; @@ -1744,6 +1837,7 @@ export { createNewUserWrapper, createNewUser, loginUser, + loginUserWithMicrosoft, resetPassword, updateUserById, deleteUserById, diff --git a/Servers/database/migrations/20251002024619-create-sso-configuration-table.js b/Servers/database/migrations/20251002024619-create-sso-configuration-table.js new file mode 100644 index 0000000000..90b84bc499 --- /dev/null +++ b/Servers/database/migrations/20251002024619-create-sso-configuration-table.js @@ -0,0 +1,108 @@ +'use strict'; +const { getTenantHash } = require("../../dist/tools/getTenantHash"); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + // Check if SSO provider ENUM exists + const [providerEnumExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM pg_type WHERE typname = 'enum_sso_configuration_providers' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (!providerEnumExists) { + await queryInterface.sequelize.query(` + CREATE TYPE enum_sso_configuration_providers AS ENUM ('AzureAD'); + `, { transaction }); + } + + const organizations = await queryInterface.sequelize.query( + `SELECT id FROM organizations;`, + { transaction } + ); + + if (organizations[0].length === 0) { + await transaction.commit(); + return; + } + + for (let organization of organizations[0]) { + try { + const tenantHash = getTenantHash(organization.id); + + // Check if table already exists + const [tableExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = '${tenantHash}' AND table_name = 'sso_configurations' + `, { transaction, type: queryInterface.sequelize.QueryTypes.SELECT }); + + if (tableExists) { + continue; + } + + // Create SSO configurations table + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS "${tenantHash}".sso_configurations ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + provider enum_sso_configuration_providers NOT NULL, + is_enabled BOOLEAN DEFAULT FALSE, + config_data JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id, provider) + ); + `, { transaction }); + + // Create indexes + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS "${tenantHash}_sso_configurations_organization_id_idx" + ON "${tenantHash}".sso_configurations (organization_id); + `, { transaction }); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS "${tenantHash}_sso_configurations_provider_idx" + ON "${tenantHash}".sso_configurations (provider); + `, { transaction }); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS "${tenantHash}_sso_configurations_is_enabled_idx" + ON "${tenantHash}".sso_configurations (is_enabled); + `, { transaction }); + + } catch (tenantError) { + console.error(`Failed to process tenant for org_id ${organization.id}:`, tenantError); + } + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const organizations = await queryInterface.sequelize.query( + `SELECT id FROM organizations;`, + { transaction } + ); + + for (let organization of organizations[0]) { + const tenantHash = getTenantHash(organization.id); + await queryInterface.sequelize.query( + `DROP TABLE IF EXISTS "${tenantHash}".sso_configurations CASCADE;`, + { transaction } + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/Servers/database/migrations/20251007034715-create-sso-fields-for-user-table.js b/Servers/database/migrations/20251007034715-create-sso-fields-for-user-table.js new file mode 100644 index 0000000000..745004f03f --- /dev/null +++ b/Servers/database/migrations/20251007034715-create-sso-fields-for-user-table.js @@ -0,0 +1,47 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const queries = [ + `ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS sso_provider enum_sso_configuration_providers, + ADD COLUMN IF NOT EXISTS sso_user_id VARCHAR(255);`, + // `ALTER TABLE public.users + // ADD CONSTRAINT unique_sso_provider_user_id UNIQUE (sso_provider, sso_user_id);`, + `ALTER TABLE public.users ALTER COLUMN password_hash DROP NOT NULL;`, + `ALTER TABLE public.users + ADD CONSTRAINT users_auth_exclusive_check + CHECK ((sso_user_id IS NULL) <> (password_hash IS NULL));` + ] + await Promise.all(queries.map(query => queryInterface.sequelize.query(query, { transaction }))); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const queries = [ + `ALTER TABLE public.users + DROP CONSTRAINT IF EXISTS users_auth_exclusive_check;`, + // `ALTER TABLE public.users + // DROP CONSTRAINT IF EXISTS unique_sso_provider_user_id;`, + `ALTER TABLE public.users + DROP COLUMN IF EXISTS sso_provider, + DROP COLUMN IF EXISTS sso_user_id;`, + `ALTER TABLE public.users ALTER COLUMN password_hash SET NOT NULL;` + ] + await Promise.all(queries.map(query => queryInterface.sequelize.query(query, { transaction }))); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/Servers/domain.layer/interfaces/i.ssoConfig.ts b/Servers/domain.layer/interfaces/i.ssoConfig.ts new file mode 100644 index 0000000000..02d878146b --- /dev/null +++ b/Servers/domain.layer/interfaces/i.ssoConfig.ts @@ -0,0 +1,11 @@ +export type SSOProvider = "AzureAD"; + +export interface ISSOConfiguration { + id?: number; + organization_id: number; + provider: SSOProvider; + is_enabled: boolean; + config_data: object; + created_at?: Date; + updated_at?: Date; +} \ No newline at end of file diff --git a/Servers/domain.layer/models/ssoConfig/ssoConfig.model.ts b/Servers/domain.layer/models/ssoConfig/ssoConfig.model.ts new file mode 100644 index 0000000000..3f75b5fa8f --- /dev/null +++ b/Servers/domain.layer/models/ssoConfig/ssoConfig.model.ts @@ -0,0 +1,56 @@ +import { Column, DataType, ForeignKey, Model, Table } from "sequelize-typescript"; +import { ISSOConfiguration, SSOProvider } from "../../interfaces/i.ssoConfig"; +import { OrganizationModel } from "../organization/organization.model"; + +@Table({ + tableName: "sso_configurations", +}) +export class SSOConfigurationModel extends Model implements ISSOConfiguration { + @Column({ + type: DataType.INTEGER, + autoIncrement: true, + primaryKey: true, + }) + id?: number; + + @ForeignKey(() => OrganizationModel) + @Column({ + type: DataType.INTEGER, + allowNull: false, + }) + organization_id!: number; + + @Column({ + type: DataType.ENUM("AzureAD"), + allowNull: false, + }) + provider!: SSOProvider; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false, + }) + is_enabled!: boolean; + + @Column({ + type: DataType.JSONB, + allowNull: false, + }) + config_data!: object; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW, + }) + created_at?: Date; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW, + }) + updated_at?: Date; + +} \ No newline at end of file diff --git a/Servers/domain.layer/models/user/user.model.ts b/Servers/domain.layer/models/user/user.model.ts index d3b7eb5d2b..f1b2ad5117 100644 --- a/Servers/domain.layer/models/user/user.model.ts +++ b/Servers/domain.layer/models/user/user.model.ts @@ -53,6 +53,7 @@ import { } from "../../exceptions/custom.exception"; import bcrypt from "bcrypt"; import { OrganizationModel } from "../organization/organization.model"; +import { SSOProvider } from "../../interfaces/i.ssoConfig"; @Table({ tableName: "users", @@ -83,7 +84,7 @@ export class UserModel extends Model { @Column({ type: DataType.STRING, }) - password_hash!: string; + password_hash!: string | null; @ForeignKey(() => RoleModel) @Column({ @@ -115,6 +116,18 @@ export class UserModel extends Model { }) organization_id?: number; + @Column({ + type: DataType.ENUM("AzureAD"), + allowNull: true, + }) + sso_provider?: SSOProvider; + + @Column({ + type: DataType.STRING, + allowNull: true, + }) + sso_user_id?: string; + @Column({ type: DataType.INTEGER, allowNull: true, @@ -154,9 +167,9 @@ export class UserModel extends Model { name: string, surname: string, email: string, - password: string, role_id: number, - organization_id: number + organization_id: number, + password: string | null = null, sso_provider?: SSOProvider, sso_user_id?: string ): Promise { // Validate email if (!emailValidation(email)) { @@ -164,8 +177,8 @@ export class UserModel extends Model { } // Validate password - const passwordValidationResult = passwordValidation(password); - if (!passwordValidationResult.isValid) { + const passwordValidationResult = password ? passwordValidation(password) : null; + if (password && !passwordValidationResult!.isValid) { throw new ValidationException( "Password must contain at least one lowercase letter, one uppercase letter, one digit, and be at least 8 characters long", "password", @@ -188,7 +201,7 @@ export class UserModel extends Model { } // Hash the password - const password_hash = await bcrypt.hash(password, 10); + const password_hash = password ? await bcrypt.hash(password, 10) : null; // Create and return the user model instance const user = new UserModel(); @@ -201,6 +214,8 @@ export class UserModel extends Model { user.last_login = new Date(); user.is_demo = false; user.organization_id = organization_id; + user.sso_provider = sso_provider; + user.sso_user_id = sso_user_id; return user; } @@ -374,6 +389,9 @@ export class UserModel extends Model { * } */ async comparePassword(password: string): Promise { + if (!this.password_hash) { + return false; + } return bcrypt.compare(password, this.password_hash); } @@ -669,6 +687,9 @@ export class UserModel extends Model { created_at: this.created_at?.toISOString(), last_login: this.last_login?.toISOString(), is_demo: this.is_demo, + organization_id: this.organization_id, + sso_provider: this.sso_provider, + sso_user_id: this.sso_user_id, }; } diff --git a/Servers/index.ts b/Servers/index.ts index 374d8bc856..a91c8a4d12 100644 --- a/Servers/index.ts +++ b/Servers/index.ts @@ -36,6 +36,8 @@ import subscriptionRoutes from "./routes/subscription.route"; import autoDriverRoutes from "./routes/autoDriver.route"; import taskRoutes from "./routes/task.route"; import slackWebhookRoutes from "./routes/slackWebhook.route"; +import ssoRoutes from "./routes/ssoConfig.route" + import tokenRoutes from "./routes/tokens.route"; import shareLinkRoutes from "./routes/shareLink.route"; import automation from "./routes/automation.route.js"; @@ -176,6 +178,7 @@ try { app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerDoc)); app.use("/api/policies", policyRoutes); app.use("/api/slackWebhooks", slackWebhookRoutes); + app.use("/api/ssoConfig", ssoRoutes); app.use("/api/tokens", tokenRoutes); app.use("/api/shares", shareLinkRoutes); app.use("/api/file-manager", fileManagerRoutes); diff --git a/Servers/package-lock.json b/Servers/package-lock.json index 9692c5d8fa..d012526175 100644 --- a/Servers/package-lock.json +++ b/Servers/package-lock.json @@ -14,6 +14,7 @@ "@awaismirza/bypass-cors": "^1.1.2", "@aws-sdk/client-ses": "^3.901.0", "@azure/communication-email": "^1.1.0", + "@azure/msal-node": "^3.8.0", "@slack/web-api": "^7.10.0", "@smithy/node-http-handler": "^4.3.0", "@types/passport-jwt": "^4.0.1", @@ -1018,6 +1019,38 @@ "node": ">=20.0.0" } }, + "node_modules/@azure/msal-common": { + "version": "15.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.0.tgz", + "integrity": "sha512-8oF6nj02qX7eE/6+wFT5NluXRHc05AgdCC3fJnkjiJooq8u7BcLmxaYYSwc2AfEkWRMRi6Eyvvbeqk4U4412Ag==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.0.tgz", + "integrity": "sha512-23BXm82Mp5XnRhrcd4mrHa0xuUNRp96ivu3nRatrfdAqjoeWAGyD0eEAafxAOHAEWWmdlyFK4ELFcdziXyw2sA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", diff --git a/Servers/package.json b/Servers/package.json index 22fb77e28c..206088846e 100644 --- a/Servers/package.json +++ b/Servers/package.json @@ -21,6 +21,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.2", "@awaismirza/bypass-cors": "^1.1.2", + "@azure/msal-node": "^3.8.0", "@aws-sdk/client-ses": "^3.901.0", "@azure/communication-email": "^1.1.0", "@slack/web-api": "^7.10.0", diff --git a/Servers/routes/organization.route.ts b/Servers/routes/organization.route.ts index 29328fa769..b375a0008c 100644 --- a/Servers/routes/organization.route.ts +++ b/Servers/routes/organization.route.ts @@ -26,6 +26,7 @@ const router = express.Router(); import { createOrganization, + getAllOrganizations, getOrganizationById, updateOrganizationById, getOrganizationsExists, @@ -34,6 +35,22 @@ import { import authenticateJWT from "../middleware/auth.middleware"; import { checkMultiTenancy } from "../middleware/multiTenancy.middleware"; +/** + * GET /organizations + * + * Retrieves all organizations in the system. + * Public endpoint for login page organization selection. + * + * @name get/ + * @function + * @memberof module:routes/organization.route + * @inner + * @param {express.Request} req - Express request object + * @param {express.Response} res - Express response object + * @returns {Array} List of all organizations + */ +router.get("/", getAllOrganizations); + /** * GET /organizations/exists * diff --git a/Servers/routes/ssoConfig.route.ts b/Servers/routes/ssoConfig.route.ts new file mode 100644 index 0000000000..7782ae6107 --- /dev/null +++ b/Servers/routes/ssoConfig.route.ts @@ -0,0 +1,16 @@ +import express from "express"; +import authenticateJWT from "../middleware/auth.middleware"; +import { checkSSOStatusByOrgId, disableSSO, enableSSO, getSSOConfigForOrg, saveSSOConfig } from "../controllers/ssoConfig.ctrl"; +const router = express.Router(); + +// Public endpoint for login page to check SSO status by organization ID +router.get("/check-status", checkSSOStatusByOrgId); + +router.get("/", /** authenticateJWT, **/ getSSOConfigForOrg); +router.put("/", authenticateJWT, saveSSOConfig); +router.put("/enable", authenticateJWT, enableSSO); +router.put("/disable", authenticateJWT, disableSSO); + +// router.delete("/", ); + +export default router; \ No newline at end of file diff --git a/Servers/routes/user.route.ts b/Servers/routes/user.route.ts index 1ba7914e6c..ead8239e2d 100644 --- a/Servers/routes/user.route.ts +++ b/Servers/routes/user.route.ts @@ -46,6 +46,7 @@ import { calculateProgress, ChangePassword, refreshAccessToken, + loginUserWithMicrosoft, uploadUserProfilePhoto, getUserProfilePhoto, deleteUserProfilePhoto, @@ -132,6 +133,8 @@ const loginLimiter = rateLimit({ }); router.post("/login", loginLimiter, loginUser); +router.post("/login-microsoft", loginUserWithMicrosoft); + router.post("/refresh-token", authLimiter, refreshAccessToken); /** diff --git a/Servers/scripts/createNewTenant.ts b/Servers/scripts/createNewTenant.ts index 8e8bab5da9..479f99d23a 100644 --- a/Servers/scripts/createNewTenant.ts +++ b/Servers/scripts/createNewTenant.ts @@ -1936,6 +1936,29 @@ export const createNewTenant = async ( { transaction } ); + await sequelize.query( + `CREATE TABLE IF NOT EXISTS "${tenantHash}".sso_configurations ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + provider enum_sso_configuration_providers NOT NULL, + is_enabled BOOLEAN DEFAULT FALSE, + config_data JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id, provider) + );`, + { transaction } + ); + + // Create indexes for SSO configurations + await Promise.all( + [ + `CREATE INDEX IF NOT EXISTS "${tenantHash}_sso_configurations_organization_id_idx" ON "${tenantHash}".sso_configurations (organization_id);`, + `CREATE INDEX IF NOT EXISTS "${tenantHash}_sso_configurations_provider_idx" ON "${tenantHash}".sso_configurations (provider);`, + `CREATE INDEX IF NOT EXISTS "${tenantHash}_sso_configurations_is_enabled_idx" ON "${tenantHash}".sso_configurations (is_enabled);`, + ].map((query) => sequelize.query(query, { transaction })) + ); + await sequelize.query( `CREATE TABLE IF NOT EXISTS "${tenantHash}".advisor_conversations ( id SERIAL PRIMARY KEY, diff --git a/Servers/scripts/resetDatabase.ts b/Servers/scripts/resetDatabase.ts index d18413d3cd..5bf5753e43 100644 --- a/Servers/scripts/resetDatabase.ts +++ b/Servers/scripts/resetDatabase.ts @@ -57,9 +57,9 @@ async function resetDatabase() { "VerifyWise", "Admin", "verifywise@email.com", - "MyJH4rTm!@.45L0wm", 1, - 1 + 1, + "MyJH4rTm!@.45L0wm", ), transaction, true diff --git a/Servers/utils/ssoConfig.utils.ts b/Servers/utils/ssoConfig.utils.ts new file mode 100644 index 0000000000..a6921ae4fc --- /dev/null +++ b/Servers/utils/ssoConfig.utils.ts @@ -0,0 +1,115 @@ +import { Transaction } from "sequelize"; +import { sequelize } from "../database/db"; +import { ISSOConfiguration, SSOProvider } from "../domain.layer/interfaces/i.ssoConfig"; +import { SSOConfigurationModel } from "../domain.layer/models/ssoConfig/ssoConfig.model"; +import Jwt from "jsonwebtoken"; + +export const getSSOConfigQuery = async ( + provider: SSOProvider, + tenant: string +) => { + const result = await sequelize.query( + `SELECT * FROM "${tenant}".sso_configurations WHERE provider = :provider`, + { + replacements: { provider }, + } + ) as [SSOConfigurationModel[], number]; + return result[0][0]; +} + +export const getAzureADConfigQuery = async ( + tenant: string, + transaction: Transaction | null = null +): Promise<{ client_id: string; client_secret: string; tenant_id: string }> => { + const result = await sequelize.query( + `SELECT config_data FROM "${tenant}".sso_configurations WHERE provider = 'AzureAD'`, + { + ...(transaction ? { transaction } : {}), + } + ) as [{ config_data: { client_id: string; client_secret: string; tenant_id: string } }[], number]; + if (result[0].length === 0) { + throw new Error("SSO configuration not found for the given provider"); + } + return result[0][0].config_data as { client_id: string; client_secret: string; tenant_id: string }; +} + +export const saveSSOConfigQuery = async ( + provider: SSOProvider, + ssoConfigData: ISSOConfiguration["config_data"], + tenant: string +): Promise => { + let encryptedSecret = ""; + if (provider === "AzureAD") { + encryptedSecret = Jwt.sign((ssoConfigData as { + client_id: string; + client_secret: string; + tenant_id: string; + }).client_secret, process.env.SSO_SECRET as string); + ssoConfigData = { + ...(ssoConfigData as { + client_id: string; + client_secret: string; + tenant_id: string; + }), + client_secret: encryptedSecret, + }; + } else { + throw new Error("Unsupported SSO provider"); + } + + const result = await sequelize.query( + `INSERT INTO "${tenant}".sso_configurations ( + provider, config_data, created_at, updated_at + ) VALUES ( + :provider, :config_data, NOW(), NOW() + ) + ON CONFLICT (provider) + DO UPDATE SET + config_data = EXCLUDED.config_data, + updated_at = NOW() + RETURNING *`, + { + replacements: { + provider, + config_data: JSON.stringify(ssoConfigData), + } + } + ) as [SSOConfigurationModel[], number]; + return result[0][0]; +} + +export const enableSSOQuery = async ( + provider: SSOProvider, + tenant: string +): Promise => { + const result = await sequelize.query( + `UPDATE "${tenant}".sso_configurations SET is_enabled = TRUE, updated_at = NOW() + WHERE provider = :provider RETURNING *`, + { + replacements: { provider }, + } + ) as [SSOConfigurationModel[], number]; + if (result[0].length === 0) { + throw new Error("SSO configuration not found for the given provider"); + } else { + return; + } +} + +export const disableSSOQuery = async ( + provider: SSOProvider, + tenant: string +): Promise => { + const result = await sequelize.query( + `UPDATE "${tenant}".sso_configurations SET is_enabled = FALSE, updated_at = NOW() + WHERE provider = :provider RETURNING *`, + { + replacements: { provider }, + } + ) as [SSOConfigurationModel[], number]; + if (result[0].length === 0) { + throw new Error("SSO configuration not found for the given provider"); + } else { + return; + } +} diff --git a/Servers/utils/user.utils.ts b/Servers/utils/user.utils.ts index 9776e21016..dcba695533 100644 --- a/Servers/utils/user.utils.ts +++ b/Servers/utils/user.utils.ts @@ -78,7 +78,8 @@ export const getAllUsersQuery = async ( * @throws {Error} If there is an error executing the SQL query. */ export const getUserByEmailQuery = async ( - email: string + email: string, + transaction: Transaction | null = null ): Promise<(UserModel & { role_name: string | null }) | null> => { try { const [userObj] = await sequelize.query( @@ -92,6 +93,7 @@ export const getUserByEmailQuery = async ( { replacements: { email }, type: QueryTypes.SELECT, + ...(transaction ? { transaction } : {}), } ); @@ -210,34 +212,35 @@ export const doesUserBelongsToOrganizationQuery = async ( */ export const createNewUserQuery = async ( user: Omit, - transaction: Transaction, + transaction: Transaction | null = null, is_demo: boolean = false ): Promise => { - const { name, surname, email, password_hash, role_id, organization_id } = - user; + const { name, surname, email, password_hash, role_id, organization_id, sso_provider, sso_user_id } = user; const created_at = new Date(); const last_login = new Date(); try { const result = await sequelize.query( - `INSERT INTO users (name, surname, email, password_hash, role_id, created_at, last_login, is_demo, organization_id) - VALUES (:name, :surname, :email, :password_hash, :role_id, :created_at, :last_login, :is_demo, :organization_id) RETURNING *`, + `INSERT INTO users (name, surname, email, password_hash, role_id, created_at, last_login, is_demo, organization_id, sso_provider, sso_user_id) + VALUES (:name, :surname, :email, :password_hash, :role_id, :created_at, :last_login, :is_demo, :organization_id, :sso_provider, :sso_user_id) RETURNING *`, { replacements: { name, surname, email, - password_hash, + password_hash: password_hash || null, role_id, created_at, last_login, is_demo, organization_id, + sso_provider: sso_provider || null, + sso_user_id: sso_user_id || null, }, mapToModel: true, model: UserModel, // type: QueryTypes.INSERT - transaction, + ...(transaction ? { transaction } : {}), } ); @@ -298,7 +301,7 @@ export const resetPasswordQuery = async ( export const updateUserByIdQuery = async ( id: number, user: Partial, - transaction: Transaction + transaction: Transaction | null = null ): Promise => { const updateUser: Partial> = {}; const setClause = ["name", "surname", "email", "role_id", "last_login"] @@ -324,7 +327,7 @@ export const updateUserByIdQuery = async ( mapToModel: true, model: UserModel, // type: QueryTypes.UPDATE, - transaction, + ...(transaction ? { transaction } : {}), }); return result[0];