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

fix(console,phrases,core): fix SAML attribute mapping (#4978)

This commit is contained in:
Darcy Ye 2023-11-28 17:08:46 +08:00 committed by GitHub
parent 2c038b25ed
commit fad8872b31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 112 additions and 111 deletions

View file

@ -5,21 +5,15 @@
*/
import { type SsoProviderName, type SsoConnectorWithProviderConfig } from '@logto/schemas';
export type AttributeMapping = {
type AttributeMapping = {
id?: string;
email?: string;
phone?: string;
name?: string;
avatar?: string;
};
export const attributeKeys = Object.freeze([
'id',
'email',
'phone',
'name',
'avatar',
]) satisfies ReadonlyArray<keyof AttributeMapping>;
export const attributeKeys = Object.freeze(['id', 'email', 'name']) satisfies ReadonlyArray<
keyof AttributeMapping
>;
export type SamlGuideFormType = {
metadataUrl?: string;
@ -60,6 +54,7 @@ export type ParsedSsoIdentityProviderConfig<T extends SsoProviderName> =
}
: T extends SsoProviderName.SAML
? {
defaultAttributeMapping: AttributeMapping;
serviceProvider: {
entityId: string;
assertionConsumerServiceUrl: string;

View file

@ -1,5 +1,5 @@
import { SsoProviderName, type SsoConnectorWithProviderConfig } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { conditionalString } from '@silverhand/essentials';
import { useContext } from 'react';
import { z } from 'zod';
@ -51,8 +51,8 @@ function BasicInfo({ ssoConnectorId, providerName, providerConfig }: Props) {
const result = providerPropertiesGuard.safeParse(providerConfig);
/**
* Used fallback to show the default value anyways but the cases should not happen.
* TODO: @darcyYe refactor to remove the manual guard.
* Should not fallback to some other manually concatenated URL, show empty string instead.
* Empty string should never show up unless the API does not work properly.
*/
return (
<>
@ -60,10 +60,10 @@ function BasicInfo({ ssoConnectorId, providerName, providerConfig }: Props) {
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={applyCustomDomain(
conditional(
result.success && result.data.serviceProvider.assertionConsumerServiceUrl
) ?? new URL(`/api/authn/saml/sso/${ssoConnectorId}`, tenantEndpoint).toString()
value={conditionalString(
result.success &&
result.data.serviceProvider.assertionConsumerServiceUrl &&
applyCustomDomain(result.data.serviceProvider.assertionConsumerServiceUrl)
)}
/>
</FormField>
@ -71,9 +71,10 @@ function BasicInfo({ ssoConnectorId, providerName, providerConfig }: Props) {
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={applyCustomDomain(
conditional(result.success && result.data.serviceProvider.entityId) ??
new URL(`/api/sso-connector/${ssoConnectorId}`, tenantEndpoint).toString()
value={conditionalString(
result.success &&
result.data.serviceProvider.entityId &&
applyCustomDomain(result.data.serviceProvider.entityId)
)}
/>
</FormField>

View file

@ -23,13 +23,14 @@
.header {
width: 100%;
font: var(--font-title-3);
> tr {
padding-bottom: _.unit(3);
}
* > th {
font: var(--font-label-2);
color: var(--color-text);
text-align: left;
}
}

View file

@ -1,32 +1,39 @@
import { useMemo } from 'react';
import { socialUserInfoGuard } from '@logto/connector-kit';
import { type SsoConnectorWithProviderConfig } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useFormContext } from 'react-hook-form';
import { z } from 'zod';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import DynamicT from '@/ds-components/DynamicT';
import TextInput from '@/ds-components/TextInput';
import {
type SamlGuideFormType,
type AttributeMapping,
attributeKeys,
} from '../../../EnterpriseSso/types.js';
import { attributeKeys, type SamlGuideFormType } from '../../../EnterpriseSso/types.js';
import * as styles from './index.module.scss';
type Props = {
isReadOnly?: boolean;
};
type Props = Pick<SsoConnectorWithProviderConfig, 'providerConfig'>;
/**
* TODO: Should align this with the guard `samlAttributeMappingGuard` defined in {@link logto/core/src/sso/types/saml.ts}.
* This only applies to SAML-protocol-based SSO connectors.
*/
const providerPropertiesGuard = z.object({
defaultAttributeMapping: socialUserInfoGuard
.pick({
id: true,
email: true,
name: true,
})
.required(),
});
const primaryKey = 'attributeMapping';
function SamlAttributeMapping({ isReadOnly }: Props) {
const { watch, register } = useFormContext<SamlGuideFormType>();
// eslint-disable-next-line react-hooks/exhaustive-deps
const attributeMapping = watch(primaryKey) ?? {};
const attributeMappingEntries = useMemo<Array<[keyof AttributeMapping, string | undefined]>>(
() => attributeKeys.map((key) => [key, attributeMapping[key] ?? '']),
[attributeMapping]
);
function SamlAttributeMapping({ providerConfig }: Props) {
const { register } = useFormContext<SamlGuideFormType>();
const result = providerPropertiesGuard.safeParse(providerConfig);
return (
<table className={styles.table}>
@ -41,22 +48,19 @@ function SamlAttributeMapping({ isReadOnly }: Props) {
</tr>
</thead>
<tbody className={styles.body}>
{attributeMappingEntries.map(([key, value]) => {
{attributeKeys.map((key) => {
return (
<tr key={key} className={styles.row}>
<td>
<CopyToClipboard className={styles.copyToClipboard} variant="border" value={key} />
</td>
<td>
{isReadOnly ? (
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={value ?? ''}
/>
) : (
<TextInput {...register(`${primaryKey}.${key}`)} placeholder={key} />
)}
<TextInput
{...register(`${primaryKey}.${key}`)}
placeholder={conditional(
result.success && result.data.defaultAttributeMapping[key]
)}
/>
</td>
</tr>
);

View file

@ -132,8 +132,11 @@ function Connection<T extends SsoProviderName>({ isDeleted, data, onUpdated }: P
<FormCard
title="enterprise_sso_details.attribute_mapping_title"
description="enterprise_sso_details.attribute_mapping_description"
descriptionInterpolation={{
name: ssoConnectorName,
}}
>
<SamlAttributeMapping />
<SamlAttributeMapping providerConfig={providerConfig} />
</FormCard>
)}
</DetailsForm>

View file

@ -21,10 +21,10 @@ import {
parseXmlMetadata,
fetchSamlMetadataXml,
handleSamlAssertion,
attributeMappingPostProcessor,
getExtendedUserInfoFromRawUserProfile,
buildSpEntityId,
buildAssertionConsumerServiceUrl,
attributeMappingPostProcessor,
} from './utils.js';
/**
@ -127,6 +127,7 @@ class SamlConnector {
const rawUserProfile = rawProfileParseResult.data;
const profileMap = attributeMappingPostProcessor(this.idpConfig.attributeMapping);
return getExtendedUserInfoFromRawUserProfile(rawUserProfile, profileMap);
}

View file

@ -1,11 +1,9 @@
import { attributeMappingPostProcessor, getExtendedUserInfoFromRawUserProfile } from './utils.js';
const expectedDefaultAttributeMapping = {
id: 'id',
id: 'nameID',
email: 'email',
phone: 'phone',
name: 'name',
avatar: 'avatar',
};
describe('attributeMappingPostProcessor', () => {
@ -15,10 +13,9 @@ describe('attributeMappingPostProcessor', () => {
});
it('should overwrite specified attributes of `expectedDefaultAttributeMapping`', () => {
expect(attributeMappingPostProcessor({ id: 'sub', avatar: 'picture' })).toEqual({
expect(attributeMappingPostProcessor({ id: 'sub' })).toEqual({
...expectedDefaultAttributeMapping,
id: 'sub',
avatar: 'picture',
});
});
});
@ -46,24 +43,4 @@ describe('getExtendedUserInfoFromRawUserProfile', () => {
avatar: 'pic.png',
});
});
it('should correctly map with specific fields specified and with extended fields unchanged', () => {
const keyMapping = attributeMappingPostProcessor({ phone: 'cell_phone', avatar: 'picture' });
const rawUserProfile = {
id: 'foo',
sub: 'bar',
email: 'test@logto.io',
cell_phone: '123456789',
picture: 'pic.png',
extend_field: 'extend_field',
};
expect(getExtendedUserInfoFromRawUserProfile(rawUserProfile, keyMapping)).toEqual({
id: 'foo',
sub: 'bar',
email: 'test@logto.io',
phone: '123456789',
avatar: 'pic.png',
extend_field: 'extend_field',
});
});
});

View file

@ -1,5 +1,6 @@
import * as validator from '@authenio/samlify-node-xmllint';
import { type Optional, conditional, appendPath } from '@silverhand/essentials';
import { type Optional, appendPath } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import { HTTPError, got } from 'got';
import * as saml from 'samlify';
import { z } from 'zod';
@ -13,11 +14,11 @@ import {
} from '../types/error.js';
import {
defaultAttributeMapping,
type CustomizableAttributeMap,
type AttributeMap,
extendedSocialUserInfoGuard,
type AttributeMap,
type ExtendedSocialUserInfo,
type SamlIdentityProviderMetadata,
type CustomizableAttributeMap,
samlIdentityProviderMetadataGuard,
} from '../types/saml.js';
@ -107,15 +108,16 @@ export const fetchSamlMetadataXml = async (metadataUrl: string): Promise<Optiona
* Get the user info from the raw user profile extracted from IdP SAML assertion.
*
* @param rawUserProfile The raw user profile extracted from IdP SAML assertion.
* @param keyMapping The full attribute mapping with default values.
* @param attributeMapping The full attribute mapping with default values.
*
* @returns The mapped social user info.
*/
export const getExtendedUserInfoFromRawUserProfile = (
rawUserProfile: Record<string, unknown>,
keyMapping: AttributeMap
attributeMapping: AttributeMap
): ExtendedSocialUserInfo => {
const keyMap = new Map(
Object.entries(keyMapping).map(([destination, source]) => [source, destination])
Object.entries(attributeMapping).map(([destination, source]) => [source, destination])
);
const mappedUserProfile = Object.fromEntries(
@ -169,7 +171,8 @@ export const handleSamlAssertion = async (
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...(Boolean(assertionResult.extract.nameID) && {
id: assertionResult.extract.nameID,
// Usually identity provider DOES NOT allow the configuration of `nameID` claim name.
nameID: assertionResult.extract.nameID,
}),
...assertionResult.extract.attributes,
};

View file

@ -7,7 +7,11 @@ import assertThat from '#src/utils/assert-that.js';
import SamlConnector from '../SamlConnector/index.js';
import { type SingleSignOnFactory } from '../index.js';
import { type SingleSignOn, type SingleSignOnConnectorData } from '../types/index.js';
import { samlConnectorConfigGuard, type SamlMetadata } from '../types/saml.js';
import {
defaultAttributeMapping,
samlConnectorConfigGuard,
type SamlMetadata,
} from '../types/saml.js';
import {
type SingleSignOnConnectorSession,
type CreateSingleSignOnSession,
@ -43,14 +47,16 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn {
}
/**
* ServiceProvider: SP metadata
* identityProvider: IdP metadata. Returns undefined if the idp config is invalid.
* `defaultAttributeMapping`: Default attribute mapping
* `serviceProvider`: SP metadata
* `identityProvider`: IdP metadata. Returns undefined if the idp config is invalid.
*/
async getConfig(): Promise<SamlMetadata> {
const serviceProvider = this.serviceProviderMetadata;
const identityProvider = await trySafe(async () => this.getSamlIdpMetadata());
return {
defaultAttributeMapping,
serviceProvider,
identityProvider,
};

View file

@ -2,16 +2,25 @@ import { socialUserInfoGuard } from '@logto/connector-kit';
import { z } from 'zod';
// Since the SAML SSO user info will extend the basic social user info (will contain extra info like `organization`, `role` etc.), but for now we haven't decide what should be included in extended user info, so we just use the basic social user info guard here to keep SSOT.
const samlAttributeMappingGuard = socialUserInfoGuard;
const samlAttributeMappingGuard = socialUserInfoGuard
.pick({
id: true,
email: true,
name: true,
})
.required();
// eslint-disable-next-line no-restricted-syntax
export const defaultAttributeMapping = Object.fromEntries(
Object.keys(samlAttributeMappingGuard.shape).map((key) => [key, key])
) as AttributeMap;
export const defaultAttributeMapping: AttributeMap = {
id: 'nameID',
email: 'email',
name: 'name',
};
const customizableAttributeMappingGuard = samlAttributeMappingGuard.partial();
export type CustomizableAttributeMap = z.infer<typeof customizableAttributeMappingGuard>;
export type AttributeMap = Required<CustomizableAttributeMap>;
const customizableAttributeMapGuard = samlAttributeMappingGuard.partial();
export type CustomizableAttributeMap = z.infer<typeof customizableAttributeMapGuard>;
export type AttributeMap = z.infer<typeof samlAttributeMappingGuard>;
/**
* This is the metadata of SAML service provider, automatically generated by `tenantId` and `ssoConnectorId`.
@ -38,21 +47,22 @@ export const samlConnectorConfigGuard = z.union([
// Config using Metadata URL
z.object({
metadataUrl: z.string(),
attributeMapping: customizableAttributeMappingGuard.optional(),
attributeMapping: customizableAttributeMapGuard.optional(),
}),
// Config using Metadata XML
z.object({
metadata: z.string(),
attributeMapping: customizableAttributeMappingGuard.optional(),
attributeMapping: customizableAttributeMapGuard.optional(),
}),
// Config using Metadata detail
samlIdentityProviderMetadataGuard.extend({
attributeMapping: customizableAttributeMappingGuard.optional(),
attributeMapping: customizableAttributeMapGuard.optional(),
}),
]);
export type SamlConnectorConfig = z.infer<typeof samlConnectorConfigGuard>;
const samlMetadataGuard = z.object({
defaultAttributeMapping: samlAttributeMappingGuard,
serviceProvider: samlServiceProviderMetadataGuard,
identityProvider: samlIdentityProviderMetadataGuard.optional(),
});

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -37,7 +37,7 @@ const enterprise_sso = {
title: 'Attribute mappings',
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
col_idp_claims: 'Claim name of identity provider',
idp_claim_tooltip: 'The claim name of the identity provider',
},

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -49,7 +49,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -47,7 +47,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -48,7 +48,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -47,7 +47,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -47,7 +47,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */

View file

@ -47,7 +47,7 @@ const enterprise_sso = {
description:
'`id` and `email` are required to sync user profile from IdP. Enter the following claim name and value in your IdP.',
/** UNTRANSLATED */
col_sp_claims: 'Claim name of Logto',
col_sp_claims: 'Value of service provider (Logto)',
/** UNTRANSLATED */
col_idp_claims: 'Claim name of identity provider',
/** UNTRANSLATED */