diff --git a/packages/console/src/pages/EnterpriseSso/types.ts b/packages/console/src/pages/EnterpriseSso/types.ts index 626300288..595acc9d4 100644 --- a/packages/console/src/pages/EnterpriseSso/types.ts +++ b/packages/console/src/pages/EnterpriseSso/types.ts @@ -31,9 +31,12 @@ export type OidcGuideFormType = { scope?: string; }; -export type GuideFormType = T extends SsoProviderName.OIDC +export type GuideFormType = T extends + | SsoProviderName.OIDC + | SsoProviderName.GOOGLE_WORKSPACE + | SsoProviderName.OKTA ? OidcGuideFormType - : T extends SsoProviderName.SAML + : T extends SsoProviderName.SAML | SsoProviderName.AZURE_AD ? SamlGuideFormType : never; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx index f7787368a..0ce4f6971 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx @@ -1,8 +1,9 @@ -import { SsoProviderName } from '@logto/schemas'; -import { type Optional } from '@silverhand/essentials'; +import { SsoProviderName, type RequestErrorBody } from '@logto/schemas'; +import { conditional, type Optional } from '@silverhand/essentials'; import cleanDeep from 'clean-deep'; +import { HTTPError } from 'ky'; import { useEffect } from 'react'; -import { useForm, FormProvider } from 'react-hook-form'; +import { useForm, FormProvider, type Path } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; @@ -15,6 +16,7 @@ import { type ParsedSsoIdentityProviderConfig, type GuideFormType, type SsoConnectorConfig, + type SamlGuideFormType, } from '@/pages/EnterpriseSso/types'; import { trySubmitSafe } from '@/utils/form'; @@ -30,16 +32,21 @@ type Props = { onUpdated: (data: SsoConnectorWithProviderConfigWithGeneric) => void; }; +const invalidConfigErrorCode = 'connector.invalid_config'; +const invalidMetadataErrorCode = 'connector.invalid_metadata'; + // This component contains only `data.config`. function Connection({ isDeleted, data, onUpdated }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { id: ssoConnectorId, providerName, providerConfig, config } = data; - const api = useApi(); + const api = useApi({ hideErrorToast: true }); const methods = useForm>(); const { + watch, + setError, formState: { isSubmitting, isDirty }, handleSubmit, reset, @@ -55,17 +62,75 @@ function Connection({ isDeleted, data, onUpdated }: P return; } - const updatedSsoConnector = await api - // TODO: @darcyYe add console test case to remove attribute mapping config. - .patch(`api/sso-connectors/${ssoConnectorId}`, { - json: { config: cleanDeep(formData) }, - }) - .json>(); + try { + const updatedSsoConnector = await api + // TODO: @darcyYe add console test case to remove attribute mapping config. + .patch(`api/sso-connectors/${ssoConnectorId}`, { + json: { config: cleanDeep(formData) }, + }) + .json>(); - toast.success(t('general.saved')); - onUpdated(updatedSsoConnector); + toast.success(t('general.saved')); + onUpdated(updatedSsoConnector); - reset(updatedSsoConnector.config); + reset(updatedSsoConnector.config); + } catch (error: unknown) { + if (error instanceof HTTPError) { + const { response } = error; + const metadata = await response.clone().json(); + + // TODO: @darcyYe refactor the generic of `GuideFormType`. + // Typescript can not infer the generic `GuideFormType`, find a better way to deal with the types later. + + if (metadata.code === invalidConfigErrorCode) { + // OIDC-based SSO connector's config only relies on the result of read from `issuer` field. + if ( + [ + SsoProviderName.OIDC, + SsoProviderName.GOOGLE_WORKSPACE, + SsoProviderName.OKTA, + ].includes(providerName) + ) { + // eslint-disable-next-line no-restricted-syntax + setError('issuer' as Path>, { + type: 'custom', + message: metadata.message, + }); + } + + // OIDC-based config has been excluded in previous condition check. + // eslint-disable-next-line no-restricted-syntax + const formConfig = watch() as SamlGuideFormType; + const key = + conditional(formConfig.metadata && 'metadata') ?? + conditional(formConfig.metadataUrl && 'metadataUrl'); + if (key) { + // eslint-disable-next-line no-restricted-syntax + setError(key as Path>, { + type: 'custom', + message: metadata.message, + }); + } + } + + // Invalid metadata error only happens for SAML based SSO connectors, when trying to init IdP with XML-format metadata. + if ( + metadata.code === invalidMetadataErrorCode && + [SsoProviderName.SAML, SsoProviderName.AZURE_AD].includes(providerName) + ) { + // Typescript can not infer the generic of setError() path. + // eslint-disable-next-line no-restricted-syntax + const formConfig = watch() as SamlGuideFormType; + const key = + conditional(formConfig.metadata && 'metadata') ?? + conditional(formConfig.metadataUrl && 'metadataUrl'); + // eslint-disable-next-line no-restricted-syntax + setError(key as Path>, { type: 'custom', message: metadata.message }); + } + } + + throw error; + } }) ); diff --git a/packages/core/package.json b/packages/core/package.json index c210fdf5c..d6eef8cb6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,6 +55,7 @@ "deepmerge": "^4.2.2", "dotenv": "^16.0.0", "etag": "^1.8.1", + "fast-xml-parser": "^4.2.5", "find-up": "^6.3.0", "got": "^13.0.0", "hash-wasm": "^4.9.0", diff --git a/packages/core/src/sso/SamlConnector/index.ts b/packages/core/src/sso/SamlConnector/index.ts index 99fb47036..cdc2b564c 100644 --- a/packages/core/src/sso/SamlConnector/index.ts +++ b/packages/core/src/sso/SamlConnector/index.ts @@ -1,4 +1,5 @@ import { type Optional } from '@silverhand/essentials'; +import { XMLValidator } from 'fast-xml-parser'; import * as saml from 'samlify'; import { z } from 'zod'; @@ -240,10 +241,26 @@ class SamlConnector { const idpMetadataXml = await this.getIdpMetadataXml(); if (idpMetadataXml) { - // eslint-disable-next-line new-cap - this._identityProvider = saml.IdentityProvider({ - metadata: idpMetadataXml, - }); + // Samlify validator swallows the error, validate the XML metadata on our own. + // Other appearance of SAML metadata validator is using '@authenio/samlify-node-xmllint', + // but this validator failed to resolve a valid XML file. Align the use of validator later on. + try { + XMLValidator.validate(idpMetadataXml, { + allowBooleanAttributes: true, + }); + } catch (error: unknown) { + throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidMetadata, { + message: SsoConnectorConfigErrorCodes.InvalidSamlXmlMetadata, + metadata: idpMetadataXml, + error, + }); + } + + this._identityProvider = + // eslint-disable-next-line new-cap + saml.IdentityProvider({ + metadata: idpMetadataXml, + }); return this._identityProvider; } diff --git a/packages/core/src/sso/types/error.ts b/packages/core/src/sso/types/error.ts index b411519ba..33be702f5 100644 --- a/packages/core/src/sso/types/error.ts +++ b/packages/core/src/sso/types/error.ts @@ -11,6 +11,7 @@ export enum SsoConnectorErrorCodes { } export enum SsoConnectorConfigErrorCodes { + InvalidSamlXmlMetadata = 'invalid_saml_xml_metadata', InvalidConfigResponse = 'invalid_config_response', FailToFetchConfig = 'fail_to_fetch_config', InvalidConnectorConfig = 'invalid_connector_config', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ab3aa59d..74b26240d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3214,6 +3214,9 @@ importers: etag: specifier: ^1.8.1 version: 1.8.1 + fast-xml-parser: + specifier: ^4.2.5 + version: 4.3.2 find-up: specifier: ^6.3.0 version: 6.3.0