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

feat(console,core): add attribute mapping (#6916)

This commit is contained in:
Darcy Ye 2025-01-06 21:40:07 +08:00 committed by GitHub
parent b0fb35f97e
commit 73694b9962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 447 additions and 64 deletions

View file

@ -5,6 +5,7 @@ export enum ApplicationDetailsTabs {
Branding = 'branding',
Permissions = 'permissions',
Organizations = 'organizations',
AttributeMapping = 'attribute-mapping',
}
export enum ApiResourceDetailsTabs {

View file

@ -0,0 +1,44 @@
@use '@/scss/underscore' as _;
.table {
width: 100%;
}
.header {
width: 100%;
> tr {
padding-bottom: _.unit(3);
}
* > th {
font: var(--font-label-2);
color: var(--color-text);
text-align: start;
}
}
.row {
width: 100%;
display: flex;
flex-direction: row;
gap: _.unit(4);
padding-bottom: _.unit(3);
align-items: center;
> td,
th {
&:not(:last-child) {
width: calc((100% - _.unit(4) - _.unit(10)) / 2);
}
&:last-child {
width: _.unit(10);
flex-shrink: 0;
}
}
}
.body {
width: 100%;
}

View file

@ -0,0 +1,217 @@
import { type UserClaim, completeUserClaims } from '@logto/core-kit';
import { type SamlApplicationResponse, samlAttributeMappingKeys } from '@logto/schemas';
import { useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import CircleMinus from '@/assets/icons/circle-minus.svg?react';
import CirclePlus from '@/assets/icons/circle-plus.svg?react';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import Button from '@/ds-components/Button';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import DynamicT from '@/ds-components/DynamicT';
import IconButton from '@/ds-components/IconButton';
import Select from '@/ds-components/Select';
import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import { trySubmitSafe } from '@/utils/form';
import styles from './AttributeMapping.module.scss';
import { camelCaseToSentenceCase } from './utils';
const defaultFormValue: Array<[UserClaim | 'id' | '', string]> = [['id', '']];
type Props = {
readonly data: SamlApplicationResponse;
readonly mutateApplication: (data?: SamlApplicationResponse) => void;
};
/**
* Type for the attribute mapping form data.
* Array of tuples containing key (UserClaim or 'id' or empty string) and value pairs
*/
type FormData = {
attributeMapping: Array<[key: UserClaim | 'id' | '', value: string]>;
};
const keyPrefix = 'attributeMapping';
const getOrderedAttributeMapping = (
attributeMapping: SamlApplicationResponse['attributeMapping'] & Partial<Record<'', string>>
) => {
return (
samlAttributeMappingKeys
.filter((key) => key in attributeMapping)
// eslint-disable-next-line no-restricted-syntax
.map((key) => [key, attributeMapping[key]] as [UserClaim | 'id', string])
);
};
function AttributeMapping({ data, mutateApplication }: Props) {
const { id, attributeMapping } = data;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
const api = useApi();
const {
watch,
register,
handleSubmit,
reset,
control,
formState: { isSubmitting, isDirty },
setValue,
} = useForm<FormData>({
defaultValues: {
attributeMapping:
Object.keys(attributeMapping).length > 0
? getOrderedAttributeMapping(attributeMapping)
: defaultFormValue,
},
});
const formValues = watch('attributeMapping');
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (isSubmitting) {
return;
}
const updatedData = await api
.patch(`api/saml-applications/${id}`, {
json: {
attributeMapping: Object.fromEntries(
formData.attributeMapping.filter(([key, value]) => Boolean(key) && Boolean(value))
),
},
})
.json<SamlApplicationResponse>();
mutateApplication(updatedData);
toast.success(t('general.saved'));
reset({
attributeMapping:
Object.keys(updatedData.attributeMapping).length > 0
? getOrderedAttributeMapping(updatedData.attributeMapping)
: defaultFormValue,
});
})
);
const existingKeys = useMemo(() => formValues.map(([key]) => key).filter(Boolean), [formValues]);
const availableKeys = useMemo(
() => completeUserClaims.filter((claim) => !existingKeys.includes(claim)),
[existingKeys]
);
return (
<DetailsForm
isSubmitting={isSubmitting}
isDirty={isDirty}
onSubmit={onSubmit}
onDiscard={reset}
>
<FormCard
title="application_details.saml_app_attribute_mapping.title"
description="application_details.saml_app_attribute_mapping.description"
learnMoreLink={{
// TODO: update this link once docs is ready
href: getDocumentationUrl('/connectors/enterprise-connectors'),
targetBlank: 'noopener',
}}
>
<table className={styles.table}>
<thead className={styles.header}>
<tr className={styles.row}>
<th>
<DynamicT forKey="application_details.saml_app_attribute_mapping.col_logto_claims" />
</th>
<th>
<DynamicT forKey="application_details.saml_app_attribute_mapping.col_sp_claims" />
</th>
<th />
</tr>
</thead>
<tbody className={styles.body}>
{formValues.map(([key, _], index) => {
return (
// eslint-disable-next-line react/no-array-index-key
<tr key={index} className={styles.row}>
<td>
{key === 'id' ? (
<CopyToClipboard displayType="block" variant="border" value={key} />
) : (
<Controller
control={control}
name={`${keyPrefix}.${index}.0` as const}
render={({ field: { onChange, value } }) => (
<Select
isSearchEnabled
value={value}
options={[
...availableKeys.map((claim) => ({
title: camelCaseToSentenceCase(claim),
value: claim,
})),
// If this is not specified, the component will fail to render the current value. The current value has been excluded in `availableKeys`.
{ value, title: camelCaseToSentenceCase(value) },
]}
onChange={(value) => {
onChange(value);
}}
/>
)}
/>
)}
</td>
<td>
<TextInput {...register(`${keyPrefix}.${index}.1`)} />
</td>
<td>
{key !== 'id' && (
<IconButton
onClick={() => {
const currentValues = [
...formValues.slice(0, index),
...formValues.slice(index + 1),
];
setValue('attributeMapping', currentValues, {
shouldDirty: true,
});
}}
>
<CircleMinus />
</IconButton>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<Button
size="small"
type="text"
disabled={availableKeys.length === 0}
icon={<CirclePlus />}
title="application_details.saml_app_attribute_mapping.add_button"
onClick={() => {
const currentValues = [...formValues];
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
currentValues.push(['', '']);
setValue('attributeMapping', currentValues, {
shouldDirty: true,
});
}}
/>
</FormCard>
</DetailsForm>
);
}
export default AttributeMapping;

View file

@ -25,6 +25,7 @@ import { applicationTypeI18nKey } from '@/types/applications';
import Branding from '../components/Branding';
import Permissions from '../components/Permissions';
import AttributeMapping from './AttributeMapping';
import Settings from './Settings';
import styles from './index.module.scss';
@ -131,10 +132,12 @@ function SamlApplicationDetailsContent({ data }: Props) {
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Settings}`}>
{t('application_details.settings')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.AttributeMapping}`}>
{t('application_details.saml_app_attribute_mapping.name')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
{t('application_details.permissions.name')}
</TabNavItem>
{/** TODO: Attribute mapping tab */}
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
{t('application_details.branding.name')}
</TabNavItem>
@ -151,6 +154,14 @@ function SamlApplicationDetailsContent({ data }: Props) {
/>
)}
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.AttributeMapping}
className={styles.tabContainer}
>
{samlApplicationData && (
<AttributeMapping data={samlApplicationData} mutateApplication={mutateSamlApplication} />
)}
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Permissions}
className={styles.tabContainer}

View file

@ -50,3 +50,15 @@ export const samlApplicationManagementApiPrefix = '/api/saml-applications';
export const samlApplicationEndpointPrefix = '/saml';
export const samlApplicationMetadataEndpointSuffix = 'metadata';
export const samlApplicationSingleSignOnEndpointSuffix = 'authn';
export const camelCaseToSentenceCase = (input: string): string => {
const words = input.split('_');
// If the first word is empty, return an empty string.
if (!words[0]) {
return '';
}
const capitalizedFirstWord = words[0].charAt(0).toUpperCase() + words[0].slice(1);
return [capitalizedFirstWord, ...words.slice(1)].join(' ');
};

View file

@ -6,7 +6,7 @@ import {
BindingType,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { removeUndefinedKeys } from '@silverhand/essentials';
import { removeUndefinedKeys, pick } from '@silverhand/essentials';
import saml from 'samlify';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
@ -86,7 +86,9 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
): Promise<SamlApplicationResponse> => {
const { name, description, customData, ...config } = patchApplicationObject;
const originalApplication = await findApplicationById(id);
const applicationData = { name, description, customData };
const applicationData = removeUndefinedKeys(
pick(patchApplicationObject, 'name', 'description', 'customData')
);
assertThat(
originalApplication.type === ApplicationType.SAML,
@ -98,7 +100,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
const [updatedApplication, upToDateSamlConfig] = await Promise.all([
Object.keys(applicationData).length > 0
? updateApplicationById(id, removeUndefinedKeys(applicationData))
? updateApplicationById(id, applicationData)
: originalApplication,
Object.keys(config).length > 0
? updateSamlApplicationConfig({

View file

@ -1,4 +1,5 @@
import { ApplicationType, BindingType, NameIdFormat } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { createApplication, deleteApplication, updateApplication } from '#src/api/application.js';
import {
@ -75,46 +76,107 @@ describe('SAML application', () => {
await deleteSamlApplication(createdSamlApplication.id);
});
it('should be able to update SAML application and get the updated one', async () => {
const createdSamlApplication = await createSamlApplication({
it.each([
{
name: 'Update with ACS URL only',
config: {
acsUrl: {
binding: BindingType.Post,
url: 'https://example.logto.io/sso/saml',
},
entityId: null,
},
},
{
name: 'Update with Entity ID only',
config: {
acsUrl: null,
entityId: 'https://example.logto.io/new-entity',
},
},
{
config: {
acsUrl: {
binding: BindingType.Post,
url: 'https://example.logto.io/sso/saml2',
},
entityId: 'https://example.logto.io/entity2',
},
},
{
name: 'Update with null values and attribute mapping',
config: {
acsUrl: null,
entityId: null,
attributeMapping: {
id: 'sub',
preferred_username: 'username',
email: 'email_address',
},
},
},
{
name: 'Update with minimal attribute mapping',
config: {
attributeMapping: {
id: 'sub',
},
},
},
])('should update SAML application - %#', async ({ name, config }) => {
const formattedName = name ? `updated-${name}` : undefined;
const initConfig = {
name: 'test',
description: 'test',
entityId: 'http://example.logto.io/foo',
});
expect(createdSamlApplication.entityId).toEqual('http://example.logto.io/foo');
};
// Create initial SAML application
const createdSamlApplication = await createSamlApplication(initConfig);
expect(createdSamlApplication.entityId).toEqual(initConfig.entityId);
// Default values
expect(createdSamlApplication.acsUrl).toEqual(null);
expect(createdSamlApplication.attributeMapping).toEqual({});
expect(createdSamlApplication.nameIdFormat).toEqual(NameIdFormat.Persistent);
expect(createdSamlApplication.encryption).toBe(null);
const newConfig = {
acsUrl: {
binding: BindingType.Post,
url: 'https://example.logto.io/sso/saml',
},
entityId: null,
nameIdFormat: NameIdFormat.EmailAddress,
encryption: {
encryptAssertion: false,
},
};
// Update application with new config
const updatedSamlApplication = await updateSamlApplication(createdSamlApplication.id, {
name: 'updated',
...newConfig,
...conditional(name && { name: formattedName }),
...config,
});
expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl);
expect(updatedSamlApplication.entityId).toEqual(newConfig.entityId);
expect(updatedSamlApplication.attributeMapping).toEqual({});
expect(updatedSamlApplication.nameIdFormat).toEqual(newConfig.nameIdFormat);
expect(updatedSamlApplication.encryption).toEqual(newConfig.encryption);
// Verify update was successful
if (config.acsUrl) {
expect(updatedSamlApplication.acsUrl).toEqual(config.acsUrl);
} else {
expect(updatedSamlApplication.acsUrl).toBe(null);
}
if (config.entityId) {
expect(updatedSamlApplication.entityId).toEqual(config.entityId);
} else {
expect(updatedSamlApplication.entityId).toBe(
config.entityId === null ? null : initConfig.entityId
);
}
if (config.attributeMapping) {
expect(updatedSamlApplication.attributeMapping).toEqual(config.attributeMapping);
} else {
expect(updatedSamlApplication.attributeMapping).toEqual({});
}
if (name) {
expect(updatedSamlApplication.name).toEqual(formattedName);
} else {
expect(updatedSamlApplication.name).toEqual(initConfig.name);
}
// Verify get returns same data
const upToDateSamlApplication = await getSamlApplication(createdSamlApplication.id);
expect(updatedSamlApplication).toEqual(upToDateSamlApplication);
expect(updatedSamlApplication.name).toEqual('updated');
expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl);
await deleteSamlApplication(updatedSamlApplication.id);
await deleteSamlApplication(createdSamlApplication.id);
});
it('can not delete/update/get non-SAML applications with `DEL /saml-applications/:id` API', async () => {

View file

@ -219,6 +219,14 @@ const application_details = {
active: 'Active',
inactive: 'Inactive',
},
saml_app_attribute_mapping: {
name: 'Attribute mappings',
title: 'Base attribute mappings',
description: 'Add attribute mappings to sync user profile from Logto to your application.',
col_logto_claims: 'Value of Logto',
col_sp_claims: 'Value name of your application',
add_button: 'Add another',
},
};
export default Object.freeze(application_details);

View file

@ -1,11 +1,20 @@
import { type ToZodObject } from '@logto/connector-kit';
import { completeUserClaims, type UserClaim } from '@logto/core-kit';
import { z } from 'zod';
export type SamlAttributeMapping = Record<string, string>;
export type SamlAttributeMapping = Partial<Record<UserClaim | 'id', string>>;
export const samlAttributeMappingGuard = z.record(
z.string()
) satisfies z.ZodType<SamlAttributeMapping>;
export const samlAttributeMappingKeys = Object.freeze(['id', ...completeUserClaims] satisfies Array<
keyof SamlAttributeMapping
>);
export const samlAttributeMappingGuard = z
.object(
Object.fromEntries(
samlAttributeMappingKeys.map((claim): [UserClaim | 'id', z.ZodString] => [claim, z.string()])
)
)
.partial() satisfies z.ZodType<SamlAttributeMapping>;
export enum BindingType {
Post = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',

View file

@ -1,3 +1,5 @@
import { z } from 'zod';
/** Scopes that reserved by Logto, which will be added to the auth request automatically. */
export enum ReservedScope {
OpenId = 'openid',
@ -14,37 +16,52 @@ export enum ReservedResource {
Organization = 'urn:logto:resource:organizations',
}
export type UserClaim =
/**
* A comprehensive list of all available user claims that can be used in SAML applications.
* These claims can be mapped to SAML attributes in the application configuration.
*
* Note: This array must include ALL possible values from UserClaim type.
* TypeScript will throw a compile-time error if any value is missing.
*/
export const completeUserClaims = [
// OIDC standard claims
| 'name'
| 'given_name'
| 'family_name'
| 'middle_name'
| 'nickname'
| 'preferred_username'
| 'profile'
| 'picture'
| 'website'
| 'email'
| 'email_verified'
| 'gender'
| 'birthdate'
| 'zoneinfo'
| 'locale'
| 'phone_number'
| 'phone_number_verified'
| 'address'
| 'updated_at'
'name',
'given_name',
'family_name',
'middle_name',
'nickname',
'preferred_username',
'profile',
'picture',
'website',
'email',
'email_verified',
'gender',
'birthdate',
'zoneinfo',
'locale',
'phone_number',
'phone_number_verified',
'address',
'updated_at',
// Custom claims
| 'username'
| 'roles'
| 'organizations'
| 'organization_data'
| 'organization_roles'
| 'custom_data'
| 'identities'
| 'sso_identities'
| 'created_at';
'username',
'roles',
'organizations',
'organization_data',
'organization_roles',
'custom_data',
'identities',
'sso_identities',
'created_at',
] as const;
/**
* Zod guard for UserClaim type, using completeUserClaims as the single source of truth
*/
export const userClaimGuard = z.enum(completeUserClaims);
export type UserClaim = z.infer<typeof userClaimGuard>;
/**
* Scopes for ID Token and Userinfo Endpoint.