mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
chore(console,core): show invalid SSO config/metadata error message inline (#5053)
This commit is contained in:
parent
70b051bded
commit
c2c1a9e359
6 changed files with 109 additions and 19 deletions
|
@ -31,9 +31,12 @@ export type OidcGuideFormType = {
|
||||||
scope?: string;
|
scope?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GuideFormType<T extends SsoProviderName> = T extends SsoProviderName.OIDC
|
export type GuideFormType<T extends SsoProviderName> = T extends
|
||||||
|
| SsoProviderName.OIDC
|
||||||
|
| SsoProviderName.GOOGLE_WORKSPACE
|
||||||
|
| SsoProviderName.OKTA
|
||||||
? OidcGuideFormType
|
? OidcGuideFormType
|
||||||
: T extends SsoProviderName.SAML
|
: T extends SsoProviderName.SAML | SsoProviderName.AZURE_AD
|
||||||
? SamlGuideFormType
|
? SamlGuideFormType
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { SsoProviderName } from '@logto/schemas';
|
import { SsoProviderName, type RequestErrorBody } from '@logto/schemas';
|
||||||
import { type Optional } from '@silverhand/essentials';
|
import { conditional, type Optional } from '@silverhand/essentials';
|
||||||
import cleanDeep from 'clean-deep';
|
import cleanDeep from 'clean-deep';
|
||||||
|
import { HTTPError } from 'ky';
|
||||||
import { useEffect } from 'react';
|
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 { toast } from 'react-hot-toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
@ -15,6 +16,7 @@ import {
|
||||||
type ParsedSsoIdentityProviderConfig,
|
type ParsedSsoIdentityProviderConfig,
|
||||||
type GuideFormType,
|
type GuideFormType,
|
||||||
type SsoConnectorConfig,
|
type SsoConnectorConfig,
|
||||||
|
type SamlGuideFormType,
|
||||||
} from '@/pages/EnterpriseSso/types';
|
} from '@/pages/EnterpriseSso/types';
|
||||||
import { trySubmitSafe } from '@/utils/form';
|
import { trySubmitSafe } from '@/utils/form';
|
||||||
|
|
||||||
|
@ -30,16 +32,21 @@ type Props<T extends SsoProviderName> = {
|
||||||
onUpdated: (data: SsoConnectorWithProviderConfigWithGeneric<T>) => void;
|
onUpdated: (data: SsoConnectorWithProviderConfigWithGeneric<T>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const invalidConfigErrorCode = 'connector.invalid_config';
|
||||||
|
const invalidMetadataErrorCode = 'connector.invalid_metadata';
|
||||||
|
|
||||||
// This component contains only `data.config`.
|
// This component contains only `data.config`.
|
||||||
function Connection<T extends SsoProviderName>({ isDeleted, data, onUpdated }: Props<T>) {
|
function Connection<T extends SsoProviderName>({ isDeleted, data, onUpdated }: Props<T>) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { id: ssoConnectorId, providerName, providerConfig, config } = data;
|
const { id: ssoConnectorId, providerName, providerConfig, config } = data;
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi({ hideErrorToast: true });
|
||||||
|
|
||||||
const methods = useForm<GuideFormType<T>>();
|
const methods = useForm<GuideFormType<T>>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
watch,
|
||||||
|
setError,
|
||||||
formState: { isSubmitting, isDirty },
|
formState: { isSubmitting, isDirty },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
@ -55,17 +62,75 @@ function Connection<T extends SsoProviderName>({ isDeleted, data, onUpdated }: P
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSsoConnector = await api
|
try {
|
||||||
// TODO: @darcyYe add console test case to remove attribute mapping config.
|
const updatedSsoConnector = await api
|
||||||
.patch(`api/sso-connectors/${ssoConnectorId}`, {
|
// TODO: @darcyYe add console test case to remove attribute mapping config.
|
||||||
json: { config: cleanDeep(formData) },
|
.patch(`api/sso-connectors/${ssoConnectorId}`, {
|
||||||
})
|
json: { config: cleanDeep(formData) },
|
||||||
.json<SsoConnectorWithProviderConfigWithGeneric<T>>();
|
})
|
||||||
|
.json<SsoConnectorWithProviderConfigWithGeneric<T>>();
|
||||||
|
|
||||||
toast.success(t('general.saved'));
|
toast.success(t('general.saved'));
|
||||||
onUpdated(updatedSsoConnector);
|
onUpdated(updatedSsoConnector);
|
||||||
|
|
||||||
reset(updatedSsoConnector.config);
|
reset(updatedSsoConnector.config);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
const { response } = error;
|
||||||
|
const metadata = await response.clone().json<RequestErrorBody>();
|
||||||
|
|
||||||
|
// TODO: @darcyYe refactor the generic of `GuideFormType<T>`.
|
||||||
|
// Typescript can not infer the generic `GuideFormType<T>`, 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<GuideFormType<T>>, {
|
||||||
|
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<GuideFormType<T>>, {
|
||||||
|
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<GuideFormType<T>>, { type: 'custom', message: metadata.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,7 @@
|
||||||
"deepmerge": "^4.2.2",
|
"deepmerge": "^4.2.2",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"etag": "^1.8.1",
|
"etag": "^1.8.1",
|
||||||
|
"fast-xml-parser": "^4.2.5",
|
||||||
"find-up": "^6.3.0",
|
"find-up": "^6.3.0",
|
||||||
"got": "^13.0.0",
|
"got": "^13.0.0",
|
||||||
"hash-wasm": "^4.9.0",
|
"hash-wasm": "^4.9.0",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { type Optional } from '@silverhand/essentials';
|
import { type Optional } from '@silverhand/essentials';
|
||||||
|
import { XMLValidator } from 'fast-xml-parser';
|
||||||
import * as saml from 'samlify';
|
import * as saml from 'samlify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -240,10 +241,26 @@ class SamlConnector {
|
||||||
const idpMetadataXml = await this.getIdpMetadataXml();
|
const idpMetadataXml = await this.getIdpMetadataXml();
|
||||||
|
|
||||||
if (idpMetadataXml) {
|
if (idpMetadataXml) {
|
||||||
// eslint-disable-next-line new-cap
|
// Samlify validator swallows the error, validate the XML metadata on our own.
|
||||||
this._identityProvider = saml.IdentityProvider({
|
// Other appearance of SAML metadata validator is using '@authenio/samlify-node-xmllint',
|
||||||
metadata: idpMetadataXml,
|
// 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;
|
return this._identityProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ export enum SsoConnectorErrorCodes {
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SsoConnectorConfigErrorCodes {
|
export enum SsoConnectorConfigErrorCodes {
|
||||||
|
InvalidSamlXmlMetadata = 'invalid_saml_xml_metadata',
|
||||||
InvalidConfigResponse = 'invalid_config_response',
|
InvalidConfigResponse = 'invalid_config_response',
|
||||||
FailToFetchConfig = 'fail_to_fetch_config',
|
FailToFetchConfig = 'fail_to_fetch_config',
|
||||||
InvalidConnectorConfig = 'invalid_connector_config',
|
InvalidConnectorConfig = 'invalid_connector_config',
|
||||||
|
|
|
@ -3214,6 +3214,9 @@ importers:
|
||||||
etag:
|
etag:
|
||||||
specifier: ^1.8.1
|
specifier: ^1.8.1
|
||||||
version: 1.8.1
|
version: 1.8.1
|
||||||
|
fast-xml-parser:
|
||||||
|
specifier: ^4.2.5
|
||||||
|
version: 4.3.2
|
||||||
find-up:
|
find-up:
|
||||||
specifier: ^6.3.0
|
specifier: ^6.3.0
|
||||||
version: 6.3.0
|
version: 6.3.0
|
||||||
|
|
Loading…
Reference in a new issue