Skip to content

Commit

Permalink
chore(console,core): show invalid SSO config/metadata error message i…
Browse files Browse the repository at this point in the history
…nline (#5053)
  • Loading branch information
darcyYe authored Dec 4, 2023
1 parent 70b051b commit c2c1a9e
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 19 deletions.
7 changes: 5 additions & 2 deletions packages/console/src/pages/EnterpriseSso/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ export type OidcGuideFormType = {
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
: T extends SsoProviderName.SAML
: T extends SsoProviderName.SAML | SsoProviderName.AZURE_AD
? SamlGuideFormType
: never;

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -15,6 +16,7 @@ import {
type ParsedSsoIdentityProviderConfig,
type GuideFormType,
type SsoConnectorConfig,
type SamlGuideFormType,
} from '@/pages/EnterpriseSso/types';
import { trySubmitSafe } from '@/utils/form';

Expand All @@ -30,16 +32,21 @@ type Props<T extends SsoProviderName> = {
onUpdated: (data: SsoConnectorWithProviderConfigWithGeneric<T>) => void;
};

const invalidConfigErrorCode = 'connector.invalid_config';
const invalidMetadataErrorCode = 'connector.invalid_metadata';

// This component contains only `data.config`.
function Connection<T extends SsoProviderName>({ isDeleted, data, onUpdated }: Props<T>) {
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<GuideFormType<T>>();

const {
watch,
setError,
formState: { isSubmitting, isDirty },
handleSubmit,
reset,
Expand All @@ -55,17 +62,75 @@ function Connection<T extends SsoProviderName>({ 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<SsoConnectorWithProviderConfigWithGeneric<T>>();
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<SsoConnectorWithProviderConfigWithGeneric<T>>();

toast.success(t('general.saved'));
onUpdated(updatedSsoConnector);

reset(updatedSsoConnector.config);
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { response } = error;
const metadata = await response.clone().json<RequestErrorBody>();

toast.success(t('general.saved'));
onUpdated(updatedSsoConnector);
// 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,
});
}

reset(updatedSsoConnector.config);
// 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;
}
})
);

Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 21 additions & 4 deletions packages/core/src/sso/SamlConnector/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/sso/types/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c2c1a9e

Please sign in to comment.