diff --git a/packages/console/src/ds-components/Textarea/index.module.scss b/packages/console/src/ds-components/Textarea/index.module.scss index 3ae2622c9..3e872d142 100644 --- a/packages/console/src/ds-components/Textarea/index.module.scss +++ b/packages/console/src/ds-components/Textarea/index.module.scss @@ -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; + } +} diff --git a/packages/console/src/ds-components/Textarea/index.tsx b/packages/console/src/ds-components/Textarea/index.tsx index 01dfa80f9..7bc7b4163 100644 --- a/packages/console/src/ds-components/Textarea/index.tsx +++ b/packages/console/src/ds-components/Textarea/index.tsx @@ -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> + )} + </> ); } diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/Settings.tsx b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/Settings.tsx index ea3b970e9..34cd27db8 100644 --- a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/Settings.tsx +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/Settings.tsx @@ -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 */ diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/index.module.scss b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/index.module.scss index c256f2441..e52a22584 100644 --- a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/index.module.scss +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/index.module.scss @@ -40,3 +40,8 @@ button.add { margin-top: _.unit(2); } + +.nameIdFormatDescription { + margin-inline-start: _.unit(2); + color: var(--color-text-secondary); +} diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/utils.ts b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/utils.ts index d9b8bf58c..4703d73cf 100644 --- a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/utils.ts +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/utils.ts @@ -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; + } +}; diff --git a/packages/core/src/saml-applications/SamlApplication/index.test.ts b/packages/core/src/saml-applications/SamlApplication/index.test.ts index 14fccc29e..cdfa18b1e 100644 --- a/packages/core/src/saml-applications/SamlApplication/index.test.ts +++ b/packages/core/src/saml-applications/SamlApplication/index.test.ts @@ -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 = { diff --git a/packages/core/src/saml-applications/SamlApplication/index.ts b/packages/core/src/saml-applications/SamlApplication/index.ts index 6961920df..4caaa333b 100644 --- a/packages/core/src/saml-applications/SamlApplication/index.ts +++ b/packages/core/src/saml-applications/SamlApplication/index.ts @@ -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, }; } } diff --git a/packages/core/src/saml-applications/SamlApplication/utils.test.ts b/packages/core/src/saml-applications/SamlApplication/utils.test.ts index ddcb12dbd..95417ef31 100644 --- a/packages/core/src/saml-applications/SamlApplication/utils.test.ts +++ b/packages/core/src/saml-applications/SamlApplication/utils.test.ts @@ -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, diff --git a/packages/core/src/saml-applications/SamlApplication/utils.ts b/packages/core/src/saml-applications/SamlApplication/utils.ts index 9980b0331..02a2aafdc 100644 --- a/packages/core/src/saml-applications/SamlApplication/utils.ts +++ b/packages/core/src/saml-applications/SamlApplication/utils.ts @@ -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 => { diff --git a/packages/core/src/saml-applications/queries/index.ts b/packages/core/src/saml-applications/queries/index.ts index 2ad6039f1..df9f84c95 100644 --- a/packages/core/src/saml-applications/queries/index.ts +++ b/packages/core/src/saml-applications/queries/index.ts @@ -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} diff --git a/packages/phrases/src/locales/en/errors/application.ts b/packages/phrases/src/locales/en/errors/application.ts index 89b692332..d052df379 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -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.', diff --git a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts index 3c7a144f9..ebfaada8b 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts @@ -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',