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

feat(console,core): enable configure on nameIdFormat and encryption (#6929)

* feat(console): enable configure on nameIdFormat and encryption

* feat(core): support configuration on nameIdFormat and encryption
This commit is contained in:
Darcy Ye 2025-01-09 11:02:59 +08:00 committed by GitHub
parent 39cef8ea47
commit b335ad01b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 314 additions and 66 deletions

View file

@ -39,3 +39,14 @@
}
}
}
.errorMessage {
font: var(--font-body-2);
color: var(--color-error);
margin-top: _.unit(1);
a {
color: var(--color-error);
text-decoration: underline;
}
}

View file

@ -14,9 +14,14 @@ function Textarea(
reference: ForwardedRef<HTMLTextAreaElement>
) {
return (
<div className={classNames(styles.container, Boolean(error) && styles.error, className)}>
<textarea {...rest} ref={reference} />
</div>
<>
<div className={classNames(styles.container, Boolean(error) && styles.error, className)}>
<textarea {...rest} ref={reference} />
</div>
{Boolean(error) && typeof error !== 'boolean' && (
<div className={styles.errorMessage}>{error}</div>
)}
</>
);
}

View file

@ -1,7 +1,13 @@
import { type SamlApplicationSecretResponse, type SamlApplicationResponse } from '@logto/schemas';
/* eslint-disable max-lines */
import { type AdminConsoleKey } from '@logto/phrases';
import {
type SamlApplicationSecretResponse,
type SamlApplicationResponse,
NameIdFormat,
} from '@logto/schemas';
import { appendPath, type Nullable } from '@silverhand/essentials';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR, { type KeyedMutator } from 'swr';
@ -15,8 +21,11 @@ import { AppDataContext } from '@/contexts/AppDataProvider';
import Button from '@/ds-components/Button';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import Select from '@/ds-components/Select';
import Switch from '@/ds-components/Switch';
import Table from '@/ds-components/Table';
import TextInput from '@/ds-components/TextInput';
import Textarea from '@/ds-components/Textarea';
import useApi, { type RequestError } from '@/hooks/use-api';
import useCustomDomain from '@/hooks/use-custom-domain';
import { trySubmitSafe } from '@/utils/form';
@ -32,15 +41,19 @@ import {
samlApplicationManagementApiPrefix,
samlApplicationMetadataEndpointSuffix,
samlApplicationSingleSignOnEndpointSuffix,
validateCertificate,
} from './utils';
export type SamlApplicationFormData = Pick<
SamlApplicationResponse,
'id' | 'description' | 'name' | 'entityId'
'id' | 'description' | 'name' | 'entityId' | 'nameIdFormat'
> & {
// Currently we only support HTTP-POST binding
// Keep the acsUrl as a string in the form data instead of the object
acsUrl: Nullable<string>;
encryptSamlAssertion: boolean;
encryptThenSignSamlAssertion: boolean;
certificate?: string;
};
type Props = {
@ -49,6 +62,25 @@ type Props = {
readonly isDeleted: boolean;
};
type NameIdFormatToTranslationKey = {
[key in NameIdFormat]: AdminConsoleKey;
};
const nameIdFormatToOptionMap = Object.freeze({
[NameIdFormat.EmailAddress]: 'application_details.saml_idp_name_id_format.email_address',
[NameIdFormat.Transient]: 'application_details.saml_idp_name_id_format.transient',
[NameIdFormat.Persistent]: 'application_details.saml_idp_name_id_format.persistent',
[NameIdFormat.Unspecified]: 'application_details.saml_idp_name_id_format.unspecified',
}) satisfies NameIdFormatToTranslationKey;
const nameIdFormatToOptionDescriptionMap = Object.freeze({
[NameIdFormat.EmailAddress]:
'application_details.saml_idp_name_id_format.email_address_description',
[NameIdFormat.Transient]: 'application_details.saml_idp_name_id_format.transient_description',
[NameIdFormat.Persistent]: 'application_details.saml_idp_name_id_format.persistent_description',
[NameIdFormat.Unspecified]: 'application_details.saml_idp_name_id_format.unspecified_description',
}) satisfies NameIdFormatToTranslationKey;
function Settings({ data, mutateApplication, isDeleted }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tenantEndpoint } = useContext(AppDataContext);
@ -60,6 +92,8 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
);
const {
watch,
control,
register,
handleSubmit,
reset,
@ -280,6 +314,75 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
}}
/>
</FormField>
<FormField title="application_details.saml_idp_name_id_format.title">
<Controller
name="nameIdFormat"
control={control}
render={({ field: { onChange, value } }) => (
<Select
options={Object.values(NameIdFormat).map((format) => ({
value: format,
title: (
<span>
{t(nameIdFormatToOptionMap[format])}
<span className={styles.nameIdFormatDescription}>
({t(nameIdFormatToOptionDescriptionMap[format])})
</span>
</span>
),
}))}
value={value}
onChange={onChange}
/>
)}
/>
</FormField>
<FormField title="application_details.saml_encryption_config.encrypt_assertion">
<Switch
label={t('application_details.saml_encryption_config.encrypt_assertion_description')}
{...register('encryptSamlAssertion')}
/>
</FormField>
{watch('encryptSamlAssertion') && (
<>
<FormField title="application_details.saml_encryption_config.encrypt_then_sign">
<Switch
label={t(
'application_details.saml_encryption_config.encrypt_then_sign_description'
)}
{...register('encryptThenSignSamlAssertion')}
/>
</FormField>
<FormField
title="application_details.saml_encryption_config.certificate"
tip={t('application_details.saml_encryption_config.certificate_tooltip')}
>
<Textarea
rows={5}
error={errors.certificate?.message}
{...register('certificate', {
validate: (value) => {
if (!value) {
return t(
'application_details.saml_encryption_config.certificate_missing_error'
);
}
return (
validateCertificate(value) ||
t(
'application_details.saml_encryption_config.certificate_invalid_format_error'
)
);
},
})}
placeholder={t(
'application_details.saml_encryption_config.certificate_placeholder'
)}
/>
</FormField>
</>
)}
</FormCard>
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} onConfirm={reset} />
@ -288,3 +391,4 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
}
export default Settings;
/* eslint-enable max-lines */

View file

@ -40,3 +40,8 @@
button.add {
margin-top: _.unit(2);
}
.nameIdFormatDescription {
margin-inline-start: _.unit(2);
color: var(--color-text-secondary);
}

View file

@ -3,14 +3,14 @@ import {
type PatchSamlApplication,
type SamlApplicationResponse,
} from '@logto/schemas';
import { removeUndefinedKeys } from '@silverhand/essentials';
import { cond, removeUndefinedKeys } from '@silverhand/essentials';
import { type SamlApplicationFormData } from './Settings';
export const parseSamlApplicationResponseToFormData = (
data: SamlApplicationResponse
): SamlApplicationFormData => {
const { id, description, name, entityId, acsUrl } = data;
const { id, description, name, entityId, acsUrl, encryption, nameIdFormat } = data;
return {
id,
@ -18,6 +18,10 @@ export const parseSamlApplicationResponseToFormData = (
name,
entityId,
acsUrl: acsUrl?.url ?? null,
nameIdFormat,
encryptSamlAssertion: encryption?.encryptAssertion ?? false,
encryptThenSignSamlAssertion: encryption?.encryptThenSign ?? false,
certificate: encryption?.certificate,
};
};
@ -27,7 +31,17 @@ export const parseFormDataToSamlApplicationRequest = (
id: string;
payload: PatchSamlApplication;
} => {
const { id, description, name, entityId, acsUrl } = data;
const {
id,
description,
name,
entityId,
acsUrl,
encryptSamlAssertion,
encryptThenSignSamlAssertion,
certificate,
nameIdFormat,
} = data;
// If acsUrl value is empty string, it should be removed. Convert it to null.
const acsUrlData = acsUrl ? { url: acsUrl, binding: BindingType.Post } : null;
@ -39,6 +53,17 @@ export const parseFormDataToSamlApplicationRequest = (
name,
entityId,
acsUrl: acsUrlData,
nameIdFormat,
...cond(
encryptSamlAssertion &&
certificate && {
certificate: {
encryptAssertion: encryptSamlAssertion,
certificate,
encryptThenSign: encryptThenSignSamlAssertion,
},
}
),
}),
};
};
@ -62,3 +87,34 @@ export const camelCaseToSentenceCase = (input: string): string => {
const capitalizedFirstWord = words[0].charAt(0).toUpperCase() + words[0].slice(1);
return [capitalizedFirstWord, ...words.slice(1)].join(' ');
};
export const validateCertificate = (certificate: string) => {
// Remove any whitespace and newline characters for consistent validation
const normalizedCert = certificate.replaceAll(/\s/g, '');
// Check if the certificate starts with the header and ends with the footer
if (
!normalizedCert.startsWith('-----BEGINCERTIFICATE-----') ||
!normalizedCert.endsWith('-----ENDCERTIFICATE-----')
) {
return false;
}
// Extract the base64 content between the header and footer
const base64Content = normalizedCert
.replace('-----BEGINCERTIFICATE-----', '')
.replace('-----ENDCERTIFICATE-----', '');
// Check if the content is valid base64
try {
if (base64Content.length % 4 !== 0) {
return false;
}
if (!/^[\d+/A-Za-z]*={0,2}$/.test(base64Content)) {
return false;
}
return true;
} catch {
return false;
}
};

View file

@ -1,3 +1,4 @@
import { NameIdFormat } from '@logto/schemas';
import nock from 'nock';
import { SamlApplication } from './index.js';
@ -25,6 +26,7 @@ describe('SamlApplication', () => {
privateKey: 'mock-private-key',
certificate: 'mock-certificate',
secret: 'mock-secret',
nameIdFormat: NameIdFormat.Persistent,
};
const mockUser = {

View file

@ -2,9 +2,14 @@
// TODO: refactor this file to reduce LOC
import { parseJson } from '@logto/connector-kit';
import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
import { type SamlAcsUrl, BindingType } from '@logto/schemas';
import {
type SamlAcsUrl,
BindingType,
type NameIdFormat,
type SamlEncryption,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { tryThat, appendPath, deduplicate } from '@silverhand/essentials';
import { tryThat, appendPath, deduplicate, type Nullable, cond } from '@silverhand/essentials';
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
import { XMLValidator } from 'fast-xml-parser';
import saml from 'samlify';
@ -41,6 +46,23 @@ type ValidSamlApplicationDetails = {
redirectUri: string;
privateKey: string;
certificate: string;
nameIdFormat: NameIdFormat;
encryption: Nullable<SamlEncryption>;
};
type SamlIdentityProviderConfig = {
entityId: string;
certificate: string;
singleSignOnUrl: string;
privateKey: string;
nameIdFormat: NameIdFormat;
encryptSamlAssertion: boolean;
};
type SamlServiceProviderConfig = {
entityId: string;
acsUrl: SamlAcsUrl;
certificate?: string;
};
// Used to check whether xml content is valid in format.
@ -68,6 +90,8 @@ const validateSamlApplicationDetails = (
privateKey,
certificate,
secret,
nameIdFormat,
encryption,
} = details;
assertThat(acsUrl, 'application.saml.acs_url_required');
@ -84,6 +108,8 @@ const validateSamlApplicationDetails = (
redirectUri: redirectUris[0],
privateKey,
certificate,
nameIdFormat,
encryption,
};
};
@ -112,12 +138,9 @@ const buildSamlIdentityProvider = ({
certificate,
singleSignOnUrl,
privateKey,
}: {
entityId: string;
certificate: string;
singleSignOnUrl: string;
privateKey: string;
}): saml.IdentityProviderInstance => {
nameIdFormat,
encryptSamlAssertion,
}: SamlIdentityProviderConfig): saml.IdentityProviderInstance => {
// eslint-disable-next-line new-cap
return saml.IdentityProvider({
entityID: entityId,
@ -133,12 +156,9 @@ const buildSamlIdentityProvider = ({
},
],
privateKey,
isAssertionEncrypted: false,
isAssertionEncrypted: encryptSamlAssertion,
loginResponseTemplate: buildLoginResponseTemplate(),
nameIDFormat: [
saml.Constants.namespace.format.emailAddress,
saml.Constants.namespace.format.persistent,
],
nameIDFormat: [nameIdFormat],
});
};
@ -193,10 +213,12 @@ export class SamlApplication {
}
public get sp(): saml.ServiceProviderInstance {
const { certificate: encryptCert, ...rest } = this.buildSpConfig();
this._sp ||= buildSamlServiceProvider({
...this.buildSpConfig(),
...rest,
certificate: this.details.certificate,
isWantAuthnRequestsSigned: this.idp.entityMeta.isWantAuthnRequestsSigned(),
...cond(encryptCert && { encryptCert }),
});
return this._sp;
}
@ -234,7 +256,8 @@ export class SamlApplication {
null,
'post',
userInfo,
this.createSamlTemplateCallback(userInfo)
this.createSamlTemplateCallback(userInfo),
this.details.encryption?.encryptThenSign
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@ -360,6 +383,7 @@ export class SamlApplication {
);
const { nameIDFormat } = this.idp.entitySetting;
assertThat(nameIDFormat, 'application.saml.name_id_format_required');
const { NameIDFormat, NameID } = buildSamlAssertionNameId(user, nameIDFormat);
const id = `ID_${generateStandardId()}`;
@ -406,19 +430,22 @@ export class SamlApplication {
};
};
private buildIdpConfig() {
private buildIdpConfig(): SamlIdentityProviderConfig {
return {
entityId: buildSamlIdentityProviderEntityId(this.tenantEndpoint, this.samlApplicationId),
privateKey: this.details.privateKey,
certificate: this.details.certificate,
singleSignOnUrl: buildSingleSignOnUrl(this.tenantEndpoint, this.samlApplicationId),
nameIdFormat: this.details.nameIdFormat,
encryptSamlAssertion: this.details.encryption?.encryptAssertion ?? false,
};
}
private buildSpConfig() {
private buildSpConfig(): SamlServiceProviderConfig {
return {
entityId: this.details.entityId,
acsUrl: this.details.acsUrl,
certificate: this.details.encryption?.certificate,
};
}
}

View file

@ -1,3 +1,5 @@
import { NameIdFormat } from '@logto/schemas';
import { generateAutoSubmitForm, buildSamlAssertionNameId } from './utils.js';
describe('buildSamlAssertionNameId', () => {
@ -8,7 +10,7 @@ describe('buildSamlAssertionNameId', () => {
email_verified: true,
};
const result = buildSamlAssertionNameId(user);
const result = buildSamlAssertionNameId(user, [NameIdFormat.EmailAddress]);
expect(result).toEqual({
NameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
@ -23,7 +25,7 @@ describe('buildSamlAssertionNameId', () => {
email_verified: false,
};
const result = buildSamlAssertionNameId(user);
const result = buildSamlAssertionNameId(user, [NameIdFormat.Persistent]);
expect(result).toEqual({
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
@ -36,7 +38,7 @@ describe('buildSamlAssertionNameId', () => {
sub: 'user123',
};
const result = buildSamlAssertionNameId(user);
const result = buildSamlAssertionNameId(user, [NameIdFormat.Persistent]);
expect(result).toEqual({
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
@ -52,7 +54,7 @@ describe('buildSamlAssertionNameId', () => {
};
const format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
const result = buildSamlAssertionNameId(user, format);
const result = buildSamlAssertionNameId(user, [format]);
expect(result).toEqual({
NameIDFormat: format,

View file

@ -1,7 +1,9 @@
// TODO: refactor this file to reduce LOC
import saml from 'samlify';
import { NameIdFormat } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import RequestError from '#src/errors/RequestError/index.js';
import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
import assertThat from '#src/utils/assert-that.js';
/**
* Determines the SAML NameID format and value based on the user's claims and IdP's NameID format.
@ -13,43 +15,41 @@ import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
*/
export const buildSamlAssertionNameId = (
user: IdTokenProfileStandardClaims,
idpNameIDFormat?: string | string[]
idpNameIDFormat: string[]
): { NameIDFormat: string; NameID: string } => {
if (idpNameIDFormat) {
// Get the first name ID format
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;
// If email format is specified, try to use email first
if (
format === saml.Constants.namespace.format.emailAddress &&
user.email &&
user.email_verified
) {
return {
NameIDFormat: format,
NameID: user.email,
};
}
// For other formats or when email is not available, use sub
if (format === saml.Constants.namespace.format.persistent) {
return {
NameIDFormat: format,
NameID: user.sub,
};
}
}
// No nameIDFormat specified, use default logic
// Use email if available
if (user.email && user.email_verified) {
// Get the first name ID format
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;
// If email format is specified, try to use email first
if (format === NameIdFormat.EmailAddress) {
assertThat(user.email, 'application.saml.missing_email_address');
assertThat(user.email_verified, 'application.saml.email_address_unverified');
return {
NameIDFormat: saml.Constants.namespace.format.emailAddress,
NameIDFormat: format,
NameID: user.email,
};
}
// Fallback to persistent format with user.sub
return {
NameIDFormat: saml.Constants.namespace.format.persistent,
NameID: user.sub,
};
// For persistent and unspecified formats, we use Logto user ID.
if (format === NameIdFormat.Persistent || format === NameIdFormat.Unspecified) {
return {
NameIDFormat: format,
NameID: user.sub,
};
}
// For transient format, we generate a random ID.
if (format === NameIdFormat.Transient) {
return {
NameIDFormat: format,
NameID: generateStandardId(),
};
}
throw new RequestError({
code: 'application.saml.unsupported_name_id_format',
details: { idpNameIDFormat, user },
});
};
export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): string => {

View file

@ -34,7 +34,10 @@ export type SamlApplicationDetails = Pick<
Application,
'id' | 'secret' | 'name' | 'description' | 'customData' | 'oidcClientMetadata'
> &
Pick<SamlApplicationConfig, 'attributeMapping' | 'entityId' | 'acsUrl'> &
Pick<
SamlApplicationConfig,
'attributeMapping' | 'entityId' | 'acsUrl' | 'encryption' | 'nameIdFormat'
> &
NullableObject<SamlApplicationSecretDetails>;
const samlApplicationDetailsGuard = Applications.guard
@ -51,6 +54,8 @@ const samlApplicationDetailsGuard = Applications.guard
attributeMapping: true,
entityId: true,
acsUrl: true,
nameIdFormat: true,
encryption: true,
})
)
.merge(
@ -66,7 +71,7 @@ const samlApplicationDetailsGuard = Applications.guard
export const createSamlApplicationQueries = (pool: CommonQueryMethods) => {
const getSamlApplicationDetailsById = async (id: string): Promise<SamlApplicationDetails> => {
const result = await pool.one(sql`
select ${fields.id} as id, ${fields.secret} as secret, ${fields.name} as name, ${fields.description} as description, ${fields.customData} as custom_data, ${fields.oidcClientMetadata} as oidc_client_metadata, ${samlApplicationConfigsFields.attributeMapping} as attribute_mapping, ${samlApplicationConfigsFields.entityId} as entity_id, ${samlApplicationConfigsFields.acsUrl} as acs_url, ${samlApplicationSecretsFields.privateKey} as private_key, ${samlApplicationSecretsFields.certificate} as certificate, ${samlApplicationSecretsFields.active} as active, ${samlApplicationSecretsFields.expiresAt} as expires_at
select ${fields.id} as id, ${fields.secret} as secret, ${fields.name} as name, ${fields.description} as description, ${fields.customData} as custom_data, ${fields.oidcClientMetadata} as oidc_client_metadata, ${samlApplicationConfigsFields.attributeMapping} as attribute_mapping, ${samlApplicationConfigsFields.entityId} as entity_id, ${samlApplicationConfigsFields.acsUrl} as acs_url, ${samlApplicationConfigsFields.encryption} as encryption, ${samlApplicationConfigsFields.nameIdFormat} as name_id_format, ${samlApplicationSecretsFields.privateKey} as private_key, ${samlApplicationSecretsFields.certificate} as certificate, ${samlApplicationSecretsFields.active} as active, ${samlApplicationSecretsFields.expiresAt} as expires_at
from ${table}
left join ${samlApplicationConfigsTable} on ${fields.id}=${samlApplicationConfigsFields.applicationId}
left join ${samlApplicationSecretsTable} on ${fields.id}=${samlApplicationSecretsFields.applicationId}

View file

@ -28,6 +28,10 @@ const application = {
can_not_delete_active_secret: 'Can not delete the active secret.',
no_active_secret: 'No active secret found.',
entity_id_required: 'Entity ID is required to generate metadata.',
name_id_format_required: 'Name ID format is required.',
unsupported_name_id_format: 'Unsupported name ID format.',
missing_email_address: 'User does not have an email address.',
email_address_unverified: 'User email address is not verified.',
invalid_certificate_pem_format: 'Invalid PEM certificate format',
acs_url_required: 'Assertion Consumer Service URL is required.',
private_key_required: 'Private key is required.',

View file

@ -219,6 +219,33 @@ const application_details = {
active: 'Active',
inactive: 'Inactive',
},
saml_idp_name_id_format: {
title: 'Name ID format',
description: 'Select the name ID format of the SAML IdP.',
persistent: 'Persistent',
persistent_description: 'Use Logto user ID as Name ID',
transient: 'Transient',
transient_description: 'Use one-time user ID as Name ID',
unspecified: 'Unspecified',
unspecified_description: 'Use Logto user ID as Name ID',
email_address: 'Email address',
email_address_description: 'Use email address as Name ID',
},
saml_encryption_config: {
encrypt_assertion: 'Encrypt SAML assertion',
encrypt_assertion_description: 'By enabling this option, the SAML assertion will be encrypted.',
encrypt_then_sign: 'Encrypt then sign',
encrypt_then_sign_description:
'By enabling this option, the SAML assertion will be encrypted and then signed; otherwise, the SAML assertion will be signed and then encrypted.',
certificate: 'Certificate',
certificate_tooltip:
'Copy and paste the x509 certificate you get from your service provider to encrypt the SAML assertion.',
certificate_placeholder:
'-----BEGIN CERTIFICATE-----\nMIICYDCCAcmgAwIBA...\n-----END CERTIFICATE-----\n',
certificate_missing_error: 'Certificate is required.',
certificate_invalid_format_error:
'Invalid certificate format detected. Please check the certificate format and try again.',
},
saml_app_attribute_mapping: {
name: 'Attribute mappings',
title: 'Base attribute mappings',