diff --git a/packages/console/src/consts/page-tabs.ts b/packages/console/src/consts/page-tabs.ts index 1542fe8b2..dcf3c524f 100644 --- a/packages/console/src/consts/page-tabs.ts +++ b/packages/console/src/consts/page-tabs.ts @@ -47,6 +47,7 @@ export enum TenantSettingsTabs { export enum EnterpriseSsoDetailsTabs { Connection = 'connection', Experience = 'experience', + IdpInitiatedAuth = 'idp-initiated-auth', } export enum OrganizationTemplateTabs { diff --git a/packages/console/src/ds-components/Select/index.module.scss b/packages/console/src/ds-components/Select/index.module.scss index 93071331a..b6827f920 100644 --- a/packages/console/src/ds-components/Select/index.module.scss +++ b/packages/console/src/ds-components/Select/index.module.scss @@ -167,3 +167,9 @@ } } } + +.errorMessage { + font: var(--font-body-2); + color: var(--color-error); + margin-top: _.unit(1); +} diff --git a/packages/console/src/ds-components/Select/index.tsx b/packages/console/src/ds-components/Select/index.tsx index 80e482875..03a3cd0e2 100644 --- a/packages/console/src/ds-components/Select/index.tsx +++ b/packages/console/src/ds-components/Select/index.tsx @@ -26,7 +26,7 @@ type Props = { readonly options: Array>; readonly onChange?: (value?: T) => void; readonly isReadOnly?: boolean; - readonly error?: string | boolean; + readonly error?: ReactNode; readonly placeholder?: ReactNode; readonly isClearable?: boolean; readonly size?: 'small' | 'medium' | 'large'; @@ -150,6 +150,9 @@ function Select({ {isOpen ? : } + {Boolean(error) && typeof error !== 'boolean' && ( +
{error}
+ )} ; +}; + +function ConfigForm({ + ssoConnector, + applications, + idpInitiatedAuthConfig, + mutateIdpInitiatedConfig, +}: FormProps) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { getTo } = useTenantPathname(); + const api = useApi(); + + const { + control, + register, + formState: { isDirty, isSubmitting, errors }, + reset, + setValue, + handleSubmit, + watch, + } = useForm({ + defaultValues: parseResponseToFormData(idpInitiatedAuthConfig), + }); + + const isIdpInitiatedSsoEnabled = watch('isIdpInitiatedSsoEnabled'); + const defaultApplicationId = watch('config.defaultApplicationId'); + const autoSendAuthorizationRequest = watch('config.autoSendAuthorizationRequest'); + + const defaultApplication = useMemo(() => { + return applications.find((application) => application.id === defaultApplicationId); + }, [applications, defaultApplicationId]); + + const emptyApplicationsError = useMemo(() => { + if (applications.length === 0) { + return ( + , + }} + > + {t('enterprise_sso_details.idp_initiated_auth_config.empty_applications_error')} + + ); + } + }, [applications, getTo, t]); + + const defaultApplicationRedirectUris = useMemo( + () => defaultApplication?.oidcClientMetadata.redirectUris ?? [], + [defaultApplication] + ); + + const emptyRedirectUrisError = useMemo(() => { + if (defaultApplication && defaultApplicationRedirectUris.length === 0) { + return t('enterprise_sso_details.idp_initiated_auth_config.empty_redirect_uris_error'); + } + }, [defaultApplication, defaultApplicationRedirectUris.length, t]); + + // Force set autoSendAuthorizationRequest to false if the default application is set to SPA + useEffect(() => { + if (defaultApplication?.type === ApplicationType.SPA) { + setValue('config.autoSendAuthorizationRequest', false); + } + }, [defaultApplication?.type, setValue]); + + const onSubmit = handleSubmit( + trySubmitSafe(async (data) => { + const { config, isIdpInitiatedSsoEnabled } = data; + + if (isSubmitting) { + return; + } + + if (!isIdpInitiatedSsoEnabled || !config) { + await api.delete(buildIdpInitiatedAuthConfigEndpoint(ssoConnector.id)); + await mutateIdpInitiatedConfig(); + toast.success(t('general.saved')); + reset(parseResponseToFormData()); + return; + } + + const result = parseFormDataToRequestPayload(config); + if (!result.success) { + return; + } + const payload = result.data; + + const updated = await api + .put(buildIdpInitiatedAuthConfigEndpoint(ssoConnector.id), { json: payload }) + .json(); + await mutateIdpInitiatedConfig(updated); + toast.success(t('general.saved')); + reset(parseResponseToFormData(updated)); + }) + ); + + return ( + + + + + + + {isIdpInitiatedSsoEnabled && ( + <> + + ( + ({ + value: uri, + title: uri, + }))} + value={value} + error={emptyRedirectUrisError ?? errors.config?.redirectUri?.message} + onChange={onChange} + /> + )} + /> + + + ( + + )} + /> + + + )} + + )} + + + ); +} + +export default ConfigForm; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/index.module.scss b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/index.module.scss new file mode 100644 index 000000000..e70e8d7a0 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/index.module.scss @@ -0,0 +1,37 @@ +@use '@/scss/underscore' as _; + +.radioGroup { + display: flex; + gap: _.unit(3); + + > * { + flex: 1; + } +} + +.radioCardTitle { + font: var(--font-label-2); + margin-bottom: _.unit(2); +} + +.radioCardFooter { + margin-top: _.unit(4); + font: var(--font-body-3); + color: var(--color-text-secondary); +} + +.inlineLink { + text-decoration: none; + color: var(--color-text-link); + cursor: pointer; + + &:active { + color: var(--color-primary-pressed); + } +} + +div[class$='disabled'] { + .radioCardFooter { + color: var(--color-disabled); + } +} diff --git a/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/index.tsx new file mode 100644 index 000000000..961efff88 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/index.tsx @@ -0,0 +1,54 @@ +import { type Application, type SsoConnectorWithProviderConfig } from '@logto/schemas'; +import { useMemo } from 'react'; +import useSWR from 'swr'; + +import FormCard, { FormCardSkeleton } from '@/components/FormCard'; +import { type RequestError } from '@/hooks/use-api'; + +import ConfigForm from './ConfigForm'; +import useIdpInitiatedAuthConfigSWR from './use-idp-initiated-auth-config-swr'; +import { applicationsSearchUrl } from './utils'; + +type Props = { + readonly ssoConnector: SsoConnectorWithProviderConfig; +}; + +function IdpInitiatedAuth({ ssoConnector }: Props) { + const { data: applications, error: applicationError } = useSWR( + applicationsSearchUrl + ); + + const { + data: idpInitiatedAuthConfig, + mutate, + error: idpInitiatedAuthConfigError, + } = useIdpInitiatedAuthConfigSWR(ssoConnector.id); + + const isLoading = useMemo( + () => + !applications && !applicationError && !idpInitiatedAuthConfig && !idpInitiatedAuthConfigError, + [applicationError, applications, idpInitiatedAuthConfig, idpInitiatedAuthConfigError] + ); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + ); +} + +export default IdpInitiatedAuth; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/use-idp-initiated-auth-config-swr.ts b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/use-idp-initiated-auth-config-swr.ts new file mode 100644 index 000000000..260711ea0 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/use-idp-initiated-auth-config-swr.ts @@ -0,0 +1,27 @@ +import { type SsoConnectorIdpInitiatedAuthConfig } from '@logto/schemas'; +import useSWR from 'swr'; + +import useApi, { type RequestError } from '@/hooks/use-api'; +import useSwrFetcher from '@/hooks/use-swr-fetcher'; +import { shouldRetryOnError } from '@/utils/request'; + +import { buildIdpInitiatedAuthConfigEndpoint } from './utils'; + +/** + * Silently fetches the IdP initiated auth config for the given connector + * Hide error toast. + */ +const useIdpInitiatedAuthConfigSWR = (connectorId: string) => { + const fetchApi = useApi({ hideErrorToast: true }); + const fetcher = useSwrFetcher(fetchApi); + + return useSWR( + buildIdpInitiatedAuthConfigEndpoint(connectorId), + { + fetcher, + shouldRetryOnError: shouldRetryOnError({ ignore: [404] }), + } + ); +}; + +export default useIdpInitiatedAuthConfigSWR; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/utils.ts b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/utils.ts new file mode 100644 index 000000000..904690621 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/IdpInitiatedAuth/utils.ts @@ -0,0 +1,115 @@ +import { + ApplicationType, + SsoConnectorIdpInitiatedAuthConfigs, + type CreateSsoConnectorIdpInitiatedAuthConfig, + type SsoConnectorIdpInitiatedAuthConfig, +} from '@logto/schemas'; +import { t } from 'i18next'; +import { toast } from 'react-hot-toast'; + +const applicationsSearchParams = new URLSearchParams([ + ['types', ApplicationType.Traditional], + ['types', ApplicationType.SPA], + ['isThirdParty', 'false'], +]); + +export const applicationsSearchUrl = `api/applications?${applicationsSearchParams.toString()}`; + +export const buildIdpInitiatedAuthConfigEndpoint = (connectorId: string) => + `api/sso-connectors/${connectorId}/idp-initiated-auth-config`; + +type IdpInitiatedAuthConfigData = Pick< + SsoConnectorIdpInitiatedAuthConfig, + | 'defaultApplicationId' + | 'autoSendAuthorizationRequest' + | 'redirectUri' + | 'clientIdpInitiatedAuthCallbackUri' +> & { authParameters: string }; + +const authParametersGuard = SsoConnectorIdpInitiatedAuthConfigs.createGuard.shape.authParameters; + +export type IdpInitiatedAuthConfigFormData = { + isIdpInitiatedSsoEnabled: boolean; + config?: IdpInitiatedAuthConfigData; +}; + +export const parseResponseToFormData = ( + response?: SsoConnectorIdpInitiatedAuthConfig +): IdpInitiatedAuthConfigFormData => { + if (!response) { + return { + isIdpInitiatedSsoEnabled: false, + }; + } + + const { + defaultApplicationId, + autoSendAuthorizationRequest, + redirectUri, + clientIdpInitiatedAuthCallbackUri, + authParameters, + } = response; + + return { + isIdpInitiatedSsoEnabled: true, + config: { + defaultApplicationId, + autoSendAuthorizationRequest, + redirectUri, + clientIdpInitiatedAuthCallbackUri, + authParameters: JSON.stringify(authParameters, null, 2), + }, + }; +}; + +const safeParseAuthParameters = (authParameters: string) => { + try { + const data = authParametersGuard.parse(JSON.parse(authParameters)); + return { success: true, data }; + } catch (error: unknown) { + return { success: false, error }; + } +}; + +export const parseFormDataToRequestPayload = ({ + defaultApplicationId, + autoSendAuthorizationRequest = false, + clientIdpInitiatedAuthCallbackUri, + redirectUri, + authParameters, +}: IdpInitiatedAuthConfigData): + | { + success: true; + data: Partial; + } + | { success: false } => { + // Directly sign in flow + if (autoSendAuthorizationRequest) { + const parsedAuthParameters = safeParseAuthParameters(authParameters); + + if (!parsedAuthParameters.success) { + toast.error(t('admin_console.errors.invalid_parameters_format')); + return { success: false }; + } + + return { + success: true, + data: { + defaultApplicationId, + autoSendAuthorizationRequest, + redirectUri, + authParameters: parsedAuthParameters.data, + }, + }; + } + + // Client redirect flow + return { + success: true, + data: { + defaultApplicationId, + autoSendAuthorizationRequest, + clientIdpInitiatedAuthCallbackUri, + }, + }; +}; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/index.tsx index c1da2a45c..256a22894 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/index.tsx @@ -1,6 +1,10 @@ -import { type SignInExperience, type SsoConnectorWithProviderConfig } from '@logto/schemas'; +import { + SsoProviderType, + type SignInExperience, + type SsoConnectorWithProviderConfig, +} from '@logto/schemas'; import { pick } from '@silverhand/essentials'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import useSWR from 'swr'; @@ -12,6 +16,8 @@ import Skeleton from '@/components/DetailsPage/Skeleton'; import Drawer from '@/components/Drawer'; import PageMeta from '@/components/PageMeta'; import { EnterpriseSsoDetailsTabs } from '@/consts'; +import { isCloud } from '@/consts/env'; +import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import ConfirmModal from '@/ds-components/ConfirmModal'; import DynamicT from '@/ds-components/DynamicT'; import TabNav, { TabNavItem } from '@/ds-components/TabNav'; @@ -22,6 +28,7 @@ import SsoConnectorLogo from '../EnterpriseSso/SsoConnectorLogo'; import Connection from './Connection'; import Experience from './Experience'; +import IdpInitiatedAuth from './IdpInitiatedAuth'; import SsoGuide from './SsoGuide'; import { enterpriseSsoPathname } from './config'; import styles from './index.module.scss'; @@ -35,6 +42,7 @@ function EnterpriseSsoDetails() { const { ssoConnectorId, tab } = useParams(); const { isDeleted, isDeleting, onDeleteHandler } = useDeleteConnector(ssoConnectorId); + const { currentSubscriptionQuota } = useContext(SubscriptionDataContext); const [isReadmeOpen, setIsReadmeOpen] = useState(false); @@ -59,6 +67,15 @@ function EnterpriseSsoDetails() { const isDarkModeEnabled = signInExperience?.color.isDarkModeEnabled ?? false; + const isIdpInitiatedAuthEnabled = useMemo( + () => + isCloud && + ssoConnector?.providerType === SsoProviderType.SAML && + // TODO: @simeng: Replace this with new IdP-initiated auth quota guard + Boolean(currentSubscriptionQuota.enterpriseSsoLimit), + [ssoConnector, currentSubscriptionQuota] + ); + useEffect(() => { setIsDeleteAlertOpen(false); }, [pathname]); @@ -136,6 +153,16 @@ function EnterpriseSsoDetails() { > + {isIdpInitiatedAuthEnabled && ( + + + + )} {tab === EnterpriseSsoDetailsTabs.Experience && ( )} + {isIdpInitiatedAuthEnabled && tab === EnterpriseSsoDetailsTabs.IdpInitiatedAuth && ( + + )} Applications section.', + authentication_type: 'Authentication type', + auto_authentication_disabled_title: 'Redirect to client for SP-initiated SSO (Recommended)', + auto_authentication_disabled_description: + 'Redirect users to the client-side application to initiate a secure SP-initiated OIDC authentication. This will prevent the CSRF attack and increase the security of the authentication process.', + auto_authentication_enabled_title: 'Directly sign in using the IdP-initiated SSO', + auto_authentication_enabled_description: + 'Sign in directly using IdP-initiated SSO authentication. After successful sign-in, users will be redirected to the specified Redirect URI with the authorization code. (Without state and PKCE validation)', + auto_authentication_disabled_app: + 'For traditional web applications and single-page applications (SPA)', + auto_authentication_enabled_app: 'For traditional web applications only', + idp_initiated_auth_callback_uri: 'Client callback URI', + idp_initiated_auth_callback_uri_tooltip: + 'The client callback URI to initiate a SP-initiated SSO authentication flow. An ssoConnectorId will be appended to the URI as a query parameter. (e.g., https://your.domain/sso/callback?connectorId={{ssoConnectorId}})', + redirect_uri: 'Post sign-in redirect URI', + redirect_uri_tooltip: + 'The redirect URI to redirect users after successful sign-in. Logto will use this URI as the OIDC redirect URI in the authorization request. Use a dedicated URI for the IdP-initiated SSO authentication flow for better security.', + empty_redirect_uris_error: + 'No redirect URI has been registered for the application. Please add one first.', + auth_params: 'Additional authentication parameters', + auth_params_tooltip: + 'Additional parameters to be passed in the authorization request. By default only (openid profile) scopes will be requested, you can specify additional scopes or a exclusive state value here. (e.g., { "scope": "organizations email", "state": "secret_state" }).', + }, }; export default Object.freeze(enterprise_sso_details); diff --git a/packages/phrases/src/locales/en/translation/admin-console/errors.ts b/packages/phrases/src/locales/en/translation/admin-console/errors.ts index 31928687a..3aefb1454 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/errors.ts @@ -7,6 +7,7 @@ const errors = { invalid_uri_format: 'Invalid URI format', invalid_origin_format: 'Invalid URI origin format', invalid_json_format: 'Invalid JSON format', + invalid_parameters_format: 'Invalid parameters format', invalid_regex: 'Invalid regular expression', invalid_error_message_format: 'The error message format is invalid.', required_field_missing: 'Please enter {{field}}',