Skip to content

Commit

Permalink
feat(console): enable configure on nameIdFormat and encryption
Browse files Browse the repository at this point in the history
  • Loading branch information
darcyYe committed Jan 8, 2025
1 parent b3a12e4 commit 36244fa
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 9 deletions.
11 changes: 11 additions & 0 deletions packages/console/src/ds-components/Textarea/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
11 changes: 8 additions & 3 deletions packages/console/src/ds-components/Textarea/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)}
</>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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 = {
Expand All @@ -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);
Expand All @@ -60,6 +92,8 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
);

const {
watch,
control,
register,
handleSubmit,
reset,
Expand Down Expand Up @@ -280,6 +314,77 @@ 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,
// eslint-disable-next-line no-restricted-syntax
title: (
<span>
{t(nameIdFormatToOptionMap[format])}
<span className={styles.nameIdFormatDescription}>
({t(nameIdFormatToOptionDescriptionMap[format])})
</span>
</span>
) as React.ReactNode,
}))}
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) => {
console.log('certificate value', value, !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} />
Expand All @@ -288,3 +393,4 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
}

export default Settings;
/* eslint-enable max-lines */
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@
button.add {
margin-top: _.unit(2);
}

.nameIdFormatDescription {
margin-inline-start: _.unit(2);
color: var(--color-text-secondary);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ 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,
description,
name,
entityId,
acsUrl: acsUrl?.url ?? null,
nameIdFormat,
encryptSamlAssertion: encryption?.encryptAssertion ?? false,
encryptThenSignSamlAssertion: encryption?.encryptThenSign ?? false,
certificate: encryption?.certificate,
};
};

Expand All @@ -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;
Expand All @@ -39,6 +53,17 @@ export const parseFormDataToSamlApplicationRequest = (
name,
entityId,
acsUrl: acsUrlData,
nameIdFormat,
...cond(
encryptSamlAssertion &&
certificate && {
certificate: {
encryptAssertion: encryptSamlAssertion,
certificate,
encryptThenSign: encryptThenSignSamlAssertion,
},
}
),
}),
};
};
Expand All @@ -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;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit 36244fa

Please sign in to comment.