0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(console): add idp-initiated sso config page (#6688)

* feat(console): add idp-initiated SSO auth config page

add idp-initiated SSO auth config page

* fix(console): fix PR comments

fix PR comments
This commit is contained in:
simeng-li 2024-10-17 13:41:51 +08:00 committed by GitHub
parent cfc1b5eb0f
commit 64d7b6a4f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 622 additions and 3 deletions

View file

@ -47,6 +47,7 @@ export enum TenantSettingsTabs {
export enum EnterpriseSsoDetailsTabs {
Connection = 'connection',
Experience = 'experience',
IdpInitiatedAuth = 'idp-initiated-auth',
}
export enum OrganizationTemplateTabs {

View file

@ -167,3 +167,9 @@
}
}
}
.errorMessage {
font: var(--font-body-2);
color: var(--color-error);
margin-top: _.unit(1);
}

View file

@ -26,7 +26,7 @@ type Props<T> = {
readonly options: Array<Option<T>>;
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<T extends string>({
{isOpen ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</div>
</div>
{Boolean(error) && typeof error !== 'boolean' && (
<div className={styles.errorMessage}>{error}</div>
)}
<Dropdown
isFullWidth
anchorRef={anchorRef}

View file

@ -0,0 +1,310 @@
import {
type Application,
type SsoConnectorWithProviderConfig,
ApplicationType,
type SsoConnectorIdpInitiatedAuthConfig,
} from '@logto/schemas';
import { type ReactElement, useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { type KeyedMutator } from 'swr';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
import Select from '@/ds-components/Select';
import Switch from '@/ds-components/Switch';
import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
import styles from './index.module.scss';
import {
buildIdpInitiatedAuthConfigEndpoint,
type IdpInitiatedAuthConfigFormData,
parseResponseToFormData,
parseFormDataToRequestPayload,
} from './utils';
type FormProps = {
readonly ssoConnector: SsoConnectorWithProviderConfig;
readonly applications: Application[];
readonly idpInitiatedAuthConfig: SsoConnectorIdpInitiatedAuthConfig | undefined;
readonly mutateIdpInitiatedConfig: KeyedMutator<SsoConnectorIdpInitiatedAuthConfig>;
};
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<IdpInitiatedAuthConfigFormData>({
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<ReactElement | undefined>(() => {
if (applications.length === 0) {
return (
<Trans
components={{
a: <Link className={styles.inlineLink} to={getTo('/applications')} />,
}}
>
{t('enterprise_sso_details.idp_initiated_auth_config.empty_applications_error')}
</Trans>
);
}
}, [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<SsoConnectorIdpInitiatedAuthConfig>();
await mutateIdpInitiatedConfig(updated);
toast.success(t('general.saved'));
reset(parseResponseToFormData(updated));
})
);
return (
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
>
<FormCard
title="enterprise_sso_details.idp_initiated_auth_config.card_title"
description="enterprise_sso_details.idp_initiated_auth_config.card_description"
>
<FormField title="enterprise_sso_details.idp_initiated_auth_config.enable_idp_initiated_sso">
<Switch
required
description="enterprise_sso_details.idp_initiated_auth_config.enable_idp_initiated_sso_description"
{...register('isIdpInitiatedSsoEnabled')}
/>
</FormField>
{isIdpInitiatedSsoEnabled && (
<>
<FormField
isRequired
title="enterprise_sso_details.idp_initiated_auth_config.default_application"
tip={t(
'enterprise_sso_details.idp_initiated_auth_config.default_application_tooltip'
)}
>
<Controller
control={control}
name="config.defaultApplicationId"
rules={{
required: t('errors.required_field_missing', {
field: t(
'enterprise_sso_details.idp_initiated_auth_config.default_application'
),
}),
}}
render={({ field: { value, onChange } }) => (
<Select
options={applications.map((application) => ({
value: application.id,
title: `${application.name} (${application.type}, ID: ${application.id})`,
}))}
value={value}
error={emptyApplicationsError ?? errors.config?.defaultApplicationId?.message}
onChange={onChange}
/>
)}
/>
</FormField>
<FormField title="enterprise_sso_details.idp_initiated_auth_config.authentication_type">
<Controller
control={control}
name="config.autoSendAuthorizationRequest"
render={({ field: { value, onChange } }) => (
<RadioGroup
className={styles.radioGroup}
name="autoSendAuthorizationRequest"
value={value ? 'true' : 'false'}
type="card"
onChange={(value: string) => {
onChange(value === 'true');
}}
>
{[false, true].map((value) => (
<Radio
key={String(value)}
className={styles.radioElement}
value={String(value)}
isDisabled={value && defaultApplication?.type === ApplicationType.SPA}
>
<div className={styles.radioCardTitle}>
{t(
`enterprise_sso_details.idp_initiated_auth_config.auto_authentication_${
value ? 'enabled' : 'disabled'
}_title`
)}
</div>
<div>
{t(
`enterprise_sso_details.idp_initiated_auth_config.auto_authentication_${
value ? 'enabled' : 'disabled'
}_description`
)}
</div>
<div className={styles.radioCardFooter}>
{t(
`enterprise_sso_details.idp_initiated_auth_config.auto_authentication_${
value ? 'enabled' : 'disabled'
}_app`
)}
</div>
</Radio>
))}
</RadioGroup>
)}
/>
</FormField>
{/** Client redirect flow */}
{!autoSendAuthorizationRequest && (
<FormField
isRequired
title="enterprise_sso_details.idp_initiated_auth_config.idp_initiated_auth_callback_uri"
tip={t(
'enterprise_sso_details.idp_initiated_auth_config.idp_initiated_auth_callback_uri_tooltip'
)}
>
<TextInput
{...register('config.clientIdpInitiatedAuthCallbackUri', {
required: t('errors.required_field_missing', {
field: t(
'enterprise_sso_details.idp_initiated_auth_config.idp_initiated_auth_callback_uri'
),
}),
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
required
placeholder="Redirect URI"
error={errors.config?.clientIdpInitiatedAuthCallbackUri?.message}
/>
</FormField>
)}
{/** IdP-initiated direct sign-in flow */}
{autoSendAuthorizationRequest && (
<>
<FormField
isRequired
title="enterprise_sso_details.idp_initiated_auth_config.redirect_uri"
tip={t('enterprise_sso_details.idp_initiated_auth_config.redirect_uri_tooltip')}
>
<Controller
control={control}
name="config.redirectUri"
rules={{
required: t('errors.required_field_missing', {
field: t('enterprise_sso_details.idp_initiated_auth_config.redirect_uri'),
}),
}}
render={({ field: { value, onChange } }) => (
<Select
options={defaultApplicationRedirectUris.map((uri) => ({
value: uri,
title: uri,
}))}
value={value}
error={emptyRedirectUrisError ?? errors.config?.redirectUri?.message}
onChange={onChange}
/>
)}
/>
</FormField>
<FormField
title="enterprise_sso_details.idp_initiated_auth_config.auth_params"
tip={t('enterprise_sso_details.idp_initiated_auth_config.auth_params_tooltip')}
>
<Controller
control={control}
name="config.authParameters"
defaultValue="{}"
render={({ field: { value, onChange } }) => (
<CodeEditor language="json" value={value} onChange={onChange} />
)}
/>
</FormField>
</>
)}
</>
)}
</FormCard>
</DetailsForm>
);
}
export default ConfigForm;

View file

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

View file

@ -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<Application[], RequestError>(
applicationsSearchUrl
);
const {
data: idpInitiatedAuthConfig,
mutate,
error: idpInitiatedAuthConfigError,
} = useIdpInitiatedAuthConfigSWR(ssoConnector.id);
const isLoading = useMemo(
() =>
!applications && !applicationError && !idpInitiatedAuthConfig && !idpInitiatedAuthConfigError,
[applicationError, applications, idpInitiatedAuthConfig, idpInitiatedAuthConfigError]
);
if (isLoading) {
return (
<FormCard
title="enterprise_sso_details.idp_initiated_auth_config.card_title"
description="enterprise_sso_details.idp_initiated_auth_config.card_description"
>
<FormCardSkeleton />
</FormCard>
);
}
return (
<ConfigForm
ssoConnector={ssoConnector}
applications={applications ?? []}
idpInitiatedAuthConfig={idpInitiatedAuthConfig}
mutateIdpInitiatedConfig={mutate}
/>
);
}
export default IdpInitiatedAuth;

View file

@ -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<SsoConnectorIdpInitiatedAuthConfig>(fetchApi);
return useSWR<SsoConnectorIdpInitiatedAuthConfig, RequestError>(
buildIdpInitiatedAuthConfigEndpoint(connectorId),
{
fetcher,
shouldRetryOnError: shouldRetryOnError({ ignore: [404] }),
}
);
};
export default useIdpInitiatedAuthConfigSWR;

View file

@ -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<CreateSsoConnectorIdpInitiatedAuthConfig>;
}
| { 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,
},
};
};

View file

@ -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() {
>
<DynamicT forKey="enterprise_sso_details.tab_experience" />
</TabNavItem>
{isIdpInitiatedAuthEnabled && (
<TabNavItem
href={getSsoConnectorDetailsPathname(
ssoConnectorId,
EnterpriseSsoDetailsTabs.IdpInitiatedAuth
)}
>
<DynamicT forKey="enterprise_sso_details.tab_idp_initiated_auth" />
</TabNavItem>
)}
</TabNav>
{tab === EnterpriseSsoDetailsTabs.Experience && (
<Experience
@ -156,6 +183,9 @@ function EnterpriseSsoDetails() {
}}
/>
)}
{isIdpInitiatedAuthEnabled && tab === EnterpriseSsoDetailsTabs.IdpInitiatedAuth && (
<IdpInitiatedAuth ssoConnector={ssoConnector} />
)}
<ConfirmModal
isOpen={isDeleteAlertOpen}
isLoading={isDeleting}

View file

@ -5,6 +5,7 @@ const enterprise_sso_details = {
readme_drawer_subtitle: 'Set up enterprise SSO connectors to enable end users SSO',
tab_experience: 'SSO Experience',
tab_connection: 'Connection',
tab_idp_initiated_auth: 'IdP-initiated SSO',
general_settings_title: 'General',
custom_branding_title: 'Display',
custom_branding_description:
@ -73,6 +74,40 @@ const enterprise_sso_details = {
jwks_uri: 'JSON web key set endpoint',
issuer: 'Issuer',
},
idp_initiated_auth_config: {
card_title: 'IdP-initiated SSO',
card_description:
'User typically start the authentication process from your app using the SP-initiated SSO flow. DO NOT enable this feature unless absolutely necessary.',
enable_idp_initiated_sso: 'Enable IdP-initiated SSO',
enable_idp_initiated_sso_description:
"Allow enterprise users to start the authentication process directly from the identity provider's portal. Please understand the potential security risks before enabling this feature.",
default_application: 'Default application',
default_application_tooltip:
'Target application the user will be redirected to after authentication.',
empty_applications_error:
'No applications found. Please add one in the <a>Applications</a> 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);

View file

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