Skip to content

Commit

Permalink
feat(console,core): enable configure on nameIdFormat and encryption (#…
Browse files Browse the repository at this point in the history
…6929)

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

* feat(core): support configuration on nameIdFormat and encryption
  • Loading branch information
darcyYe authored Jan 9, 2025
1 parent 39cef8e commit b335ad0
Show file tree
Hide file tree
Showing 12 changed files with 314 additions and 66 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,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} />
Expand All @@ -288,3 +391,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
@@ -1,3 +1,4 @@
import { NameIdFormat } from '@logto/schemas';
import nock from 'nock';

import { SamlApplication } from './index.js';
Expand Down Expand Up @@ -25,6 +26,7 @@ describe('SamlApplication', () => {
privateKey: 'mock-private-key',
certificate: 'mock-certificate',
secret: 'mock-secret',
nameIdFormat: NameIdFormat.Persistent,
};

const mockUser = {
Expand Down
Loading

0 comments on commit b335ad0

Please sign in to comment.