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

feat(core,console): enable create and activate/deactivate SAML app secrets (#6910)

* feat: enable create and activate/deactivate SAML app secrets

* chore: update code
This commit is contained in:
Darcy Ye 2025-01-06 14:47:49 +08:00 committed by GitHub
parent 3f8f8c626b
commit 580ed25ad7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 384 additions and 50 deletions

View file

@ -1,5 +1,5 @@
import { type Nullable } from '@silverhand/essentials';
import { isValid } from 'date-fns';
import { isValid, format as formatDate } from 'date-fns';
const parseDate = (date: Nullable<string | number | Date>) => {
if (!date) {
@ -12,19 +12,49 @@ const parseDate = (date: Nullable<string | number | Date>) => {
type Props = {
readonly children: Nullable<string | number | Date>;
readonly format?: string;
};
const defaultDateFormat = 'yyyy-MM-dd';
const defaultDateTimeFormat = 'yyyy-MM-dd HH:mm:ss';
/**
* Safely display a date in the user's locale. If the date is invalid, it will display a dash.
* @param format - Optional date-fns format string to customize the date format
*/
export function LocaleDate({ children }: Props) {
return <span>{parseDate(children)?.toLocaleDateString() ?? '-'}</span>;
export function LocaleDate({ children, format = defaultDateFormat }: Props) {
const date = parseDate(children);
if (!date) {
return <span>-</span>;
}
return <span>{formatDate(date, format)}</span>;
}
/**
* Safely display a date and time in the user's locale. If the date is invalid, it will display a
* dash.
* Safely display a date and time. If the date is invalid, it will display a dash.
*
* @param format - Optional date-fns format string to customize the date and time format
*
* @example
* // Default format
* <LocaleDateTime>{new Date()}</LocaleDateTime>
* // Custom format
* <LocaleDateTime format="EEEE, MMMM do yyyy">
* {new Date()}
* </LocaleDateTime>
*
* The `format` parameter accepts any valid date-fns format string. If not provided,
* it uses the default format which includes year, month, day, hour, minute, and second.
* For format string reference, see: https://date-fns.org/docs/format
*/
export function LocaleDateTime({ children }: Props) {
return <span>{parseDate(children)?.toLocaleString() ?? '-'}</span>;
export function LocaleDateTime({ children, format = defaultDateTimeFormat }: Props) {
const date = parseDate(children);
if (!date) {
return <span>-</span>;
}
return <span>{formatDate(date, format)}</span>;
}

View file

@ -1,3 +1,8 @@
.icon {
color: var(--color-text-secondary);
> svg {
width: 20px;
height: 20px;
}
}

View file

@ -2,8 +2,11 @@ import { type SamlApplicationSecretResponse } from '@logto/schemas';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Delete from '@/assets/icons/delete.svg?react';
import Download from '@/assets/icons/download.svg?react';
import Deactivate from '@/assets/icons/moon.svg?react';
import More from '@/assets/icons/more.svg?react';
import Activate from '@/assets/icons/sun.svg?react';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import { downloadText } from '@/utils/downloader';
@ -11,12 +14,21 @@ import { buildSamlSigningCertificateFilename } from '../utils';
import styles from './index.module.scss';
type Props = {
export type Props = {
readonly appId: string;
readonly secret: SamlApplicationSecretResponse;
readonly onDelete: (id: string) => void;
readonly onActivate: (id: string) => void;
readonly onDeactivate: (id: string) => void;
};
function CertificateActionMenu({ secret: { id, certificate }, appId }: Props) {
function CertificateActionMenu({
secret: { id, certificate, active },
appId,
onDelete,
onActivate,
onDeactivate,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const onDownload = useCallback(() => {
@ -29,6 +41,39 @@ function CertificateActionMenu({ secret: { id, certificate }, appId }: Props) {
return (
<ActionMenu icon={<More className={styles.icon} />} title={t('general.more_options')}>
{active ? (
<ActionMenuItem
iconClassName={styles.icon}
icon={<Deactivate />}
onClick={() => {
onDeactivate(id);
}}
>
{t('general.deactivate')}
</ActionMenuItem>
) : (
<>
{/* Can only delete inactive certificates */}
<ActionMenuItem
type="danger"
icon={<Delete />}
onClick={() => {
onDelete(id);
}}
>
{t('general.delete')}
</ActionMenuItem>
<ActionMenuItem
iconClassName={styles.icon}
icon={<Activate />}
onClick={() => {
onActivate(id);
}}
>
{t('general.activate')}
</ActionMenuItem>
</>
)}
<ActionMenuItem iconClassName={styles.icon} icon={<Download />} onClick={onDownload}>
{t('general.download')}
</ActionMenuItem>

View file

@ -0,0 +1,128 @@
import { type SamlApplicationSecret } from '@logto/schemas';
import { addMilliseconds, addYears, format } from 'date-fns';
import { useCallback, useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import Select from '@/ds-components/Select';
import useApi from '@/hooks/use-api';
import modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
type FormData = { lifeSpanInYears: string };
type Props = {
readonly appId: string;
readonly isOpen: boolean;
readonly onClose: (createdSamlAppSecret?: SamlApplicationSecret) => void;
};
const years = Object.freeze([1, 3, 5, 10]);
// Update expiration date every minute
const intervalExpirationRefresh = 60 * 1000; // In milliseconds.
function CreateSecretModal({ appId, isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
control,
watch,
formState: { isSubmitting },
handleSubmit,
reset,
} = useForm<FormData>({ defaultValues: { lifeSpanInYears: '1' } });
const onCloseHandler = useCallback(
(created?: SamlApplicationSecret) => {
reset();
onClose(created);
},
[onClose, reset]
);
const api = useApi();
const expirationYears = watch('lifeSpanInYears');
const [expirationDate, setExpirationDate] = useState<Date>(
addYears(new Date(), Number(expirationYears))
);
// Update expiration date every minute since our options are relative to the current time (in years).
// Since we will show expire time on create secret modal in the format of `Apr 29, 1953, 12:00 AM`.
useEffect(() => {
const incrementExpirationDate = (incrementMilliseconds: number = intervalExpirationRefresh) => {
setExpirationDate(addMilliseconds(expirationDate, incrementMilliseconds));
};
const interval = setInterval(incrementExpirationDate, intervalExpirationRefresh);
incrementExpirationDate();
return () => {
clearInterval(interval);
};
}, [expirationYears]);
const submit = handleSubmit(
trySubmitSafe(async ({ lifeSpanInYears }) => {
const createdData = await api
.post(`api/saml-applications/${appId}/secrets`, {
json: { lifeSpanInYears: Number(lifeSpanInYears) },
})
.json<SamlApplicationSecret>();
toast.success(t('application_details.secrets.create_modal.created'));
onCloseHandler(createdData);
})
);
return (
<ReactModal
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onCloseHandler();
}}
>
<ModalLayout
title="application_details.secrets.create_modal.title"
footer={
<Button type="primary" title="general.create" isLoading={isSubmitting} onClick={submit} />
}
onClose={onCloseHandler}
>
<Controller
control={control}
name="lifeSpanInYears"
render={({ field: { value, onChange } }) => (
<FormField
title="application_details.secrets.create_modal.expiration"
description={
<DangerousRaw>
{t('application_details.secrets.create_modal.expiration_description', {
date: format(expirationDate, 'PPp'),
})}
</DangerousRaw>
}
>
<Select
options={years.map((count) => ({
title: t('application_details.secrets.create_modal.years', { count }),
value: String(count),
}))}
value={value}
onChange={(value) => {
onChange(value);
}}
/>
</FormField>
)}
/>
</ModalLayout>
</ReactModal>
);
}
export default CreateSecretModal;

View file

@ -1,15 +1,18 @@
import { type SamlApplicationSecretResponse, type SamlApplicationResponse } from '@logto/schemas';
import { appendPath, type Nullable } from '@silverhand/essentials';
import { useContext } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR, { type KeyedMutator } from 'swr';
import CirclePlus from '@/assets/icons/circle-plus.svg?react';
import Plus from '@/assets/icons/plus.svg?react';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { AppDataContext } from '@/contexts/AppDataProvider';
import Button from '@/ds-components/Button';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import Table from '@/ds-components/Table';
@ -19,6 +22,8 @@ import useCustomDomain from '@/hooks/use-custom-domain';
import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
import CreateSecretModal from './CreateSecretModal';
import styles from './index.module.scss';
import { useSecretTableColumns } from './use-secret-table-columns';
import {
parseFormDataToSamlApplicationRequest,
@ -48,6 +53,7 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tenantEndpoint } = useContext(AppDataContext);
const { applyDomain: applyCustomDomain } = useCustomDomain();
const [showCreateSecretModal, setShowCreateSecretModal] = useState(false);
const secrets = useSWR<SamlApplicationSecretResponse[], RequestError>(
`api/saml-applications/${data.id}/secrets`
@ -63,6 +69,8 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
mode: 'onBlur',
});
const secretsData = useMemo(() => secrets.data ?? [], [secrets.data]);
const api = useApi();
const onSubmit = handleSubmit(
@ -84,7 +92,48 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
})
);
const secretTableColumns = useSecretTableColumns({ appId: data.id });
const onDelete = useCallback(
async (id: string) => {
await api.delete(`api/saml-applications/${data.id}/secrets/${id}`);
toast.success(t('application_details.secrets.deleted'));
void secrets.mutate(secretsData.filter(({ id: secretId }) => secretId !== id));
},
[api, data.id, secrets, secretsData, t]
);
const onActivate = useCallback(
async (id: string) => {
await api.patch(`api/saml-applications/${data.id}/secrets/${id}`, { json: { active: true } });
toast.success(t('application_details.secrets.activated'));
// Activate a secret will deactivate all other secrets.
void secrets.mutate(
secretsData.map((secret) =>
secret.id === id ? { ...secret, active: true } : { ...secret, active: false }
)
);
},
[api, data.id, secrets, secretsData, t]
);
const onDeactivate = useCallback(
async (id: string) => {
await api.patch(`api/saml-applications/${data.id}/secrets/${id}`, {
json: { active: false },
});
toast.success(t('application_details.secrets.deactivated'));
void secrets.mutate(
secretsData.map((secret) => (secret.id === id ? { ...secret, active: false } : secret))
);
},
[api, data.id, secrets, secretsData, t]
);
const secretTableColumns = useSecretTableColumns({
appId: data.id,
onDelete,
onActivate,
onDeactivate,
});
return (
<>
@ -186,14 +235,49 @@ function Settings({ data, mutateApplication, isDeleted }: Props) {
</>
)}
<FormField title="application_details.saml_idp_certificates.title">
<Table
hasBorder
isRowHoverEffectDisabled
rowIndexKey="id"
isLoading={!secrets.data && !secrets.error}
errorMessage={secrets.error?.body?.message ?? secrets.error?.message}
rowGroups={[{ key: 'application_secrets', data: secrets.data ?? [] }]}
columns={secretTableColumns}
{secretsData.length === 0 && !secrets.error ? (
<>
<div className={styles.empty}>{t('application_details.secrets.empty')}</div>
<Button
icon={<Plus />}
title="application_details.secrets.create_new_secret"
onClick={() => {
setShowCreateSecretModal(true);
}}
/>
</>
) : (
<>
<Table
hasBorder
isRowHoverEffectDisabled
rowIndexKey="id"
isLoading={!secrets.data && !secrets.error}
errorMessage={secrets.error?.body?.message ?? secrets.error?.message}
rowGroups={[{ key: 'application_secrets', data: secretsData }]}
columns={secretTableColumns}
/>
<Button
size="small"
type="text"
className={styles.add}
icon={<CirclePlus />}
title="application_details.secrets.create_new_secret"
onClick={() => {
setShowCreateSecretModal(true);
}}
/>
</>
)}
<CreateSecretModal
appId={data.id}
isOpen={showCreateSecretModal}
onClose={(created) => {
if (created) {
void secrets.mutate();
}
setShowCreateSecretModal(false);
}}
/>
</FormField>
</FormCard>

View file

@ -30,3 +30,13 @@
.fingerPrint {
word-break: break-all;
}
.empty {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin: _.unit(3) 0;
}
button.add {
margin-top: _.unit(2);
}

View file

@ -8,7 +8,9 @@ import { type Column } from '@/ds-components/Table/types';
import Tag from '@/ds-components/Tag';
import { Tooltip } from '@/ds-components/Tip';
import CertificateActionMenu from './CertificateActionMenu';
import CertificateActionMenu, {
type Props as CertificateActionMenuProps,
} from './CertificateActionMenu';
import styles from './index.module.scss';
const isExpired = (expiresAt: Date | number) => compareDesc(expiresAt, new Date()) === 1;
@ -28,9 +30,14 @@ function Expired({ expiresAt }: { readonly expiresAt: Date }) {
type UseSecretTableColumns = {
appId: string;
};
} & Pick<CertificateActionMenuProps, 'onDelete' | 'onActivate' | 'onDeactivate'>;
export const useSecretTableColumns = ({ appId }: UseSecretTableColumns) => {
export const useSecretTableColumns = ({
appId,
onDelete,
onActivate,
onDeactivate,
}: UseSecretTableColumns) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const tableColumns: Array<Column<SamlApplicationSecretResponse>> = useMemo(
@ -38,13 +45,14 @@ export const useSecretTableColumns = ({ appId }: UseSecretTableColumns) => {
{
title: t('application_details.saml_idp_certificates.expires_at'),
dataIndex: 'expiresAt',
colSpan: 3,
colSpan: 5,
render: ({ expiresAt }) => (
<span>
{isExpired(expiresAt) ? (
<Expired expiresAt={new Date(expiresAt)} />
) : (
<LocaleDateTime>{expiresAt}</LocaleDateTime>
// E.g. Apr 29, 1453, 12:00:00 AM
<LocaleDateTime format="PPpp">{expiresAt}</LocaleDateTime>
)}
</span>
),
@ -52,7 +60,7 @@ export const useSecretTableColumns = ({ appId }: UseSecretTableColumns) => {
{
title: t('application_details.saml_idp_certificates.finger_print'),
dataIndex: 'fingerPrint',
colSpan: 5,
colSpan: 8,
render: ({ fingerprints }) => (
<span className={styles.fingerPrint}>{fingerprints.sha256.unformatted}</span>
),
@ -60,6 +68,7 @@ export const useSecretTableColumns = ({ appId }: UseSecretTableColumns) => {
{
title: t('application_details.saml_idp_certificates.status'),
dataIndex: 'status',
colSpan: 2,
render: ({ active }) => (
<Tag type="state" status={active ? 'success' : 'info'} variant="plain">
{t(
@ -73,12 +82,21 @@ export const useSecretTableColumns = ({ appId }: UseSecretTableColumns) => {
{
title: '',
dataIndex: 'actions',
colSpan: 2,
render: (secret) => {
return <CertificateActionMenu secret={secret} appId={appId} />;
return (
<CertificateActionMenu
secret={secret}
appId={appId}
onDelete={onDelete}
onActivate={onActivate}
onDeactivate={onDeactivate}
/>
);
},
},
],
[appId, t]
[appId, onActivate, onDeactivate, onDelete, t]
);
return tableColumns;

View file

@ -41,15 +41,15 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
*/
const createSamlApplicationSecret = async ({
applicationId,
lifeSpanInDays = 365 * 3,
lifeSpanInYears,
isActive = false,
}: {
applicationId: string;
lifeSpanInDays?: number;
lifeSpanInYears: number;
isActive?: boolean;
}): Promise<SamlApplicationSecret> => {
const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate(
lifeSpanInDays
lifeSpanInYears
);
const createObject = {

View file

@ -1,4 +1,4 @@
import { addDays } from 'date-fns';
import { addDays, addYears } from 'date-fns';
import forge from 'node-forge';
import RequestError from '#src/errors/RequestError/index.js';
@ -7,7 +7,7 @@ import { generateKeyPairAndCertificate, calculateCertificateFingerprints } from
describe('generateKeyPairAndCertificate', () => {
it('should generate valid key pair and certificate', async () => {
const result = await generateKeyPairAndCertificate();
const result = await generateKeyPairAndCertificate(1);
// Verify private key format
expect(result.privateKey).toContain('-----BEGIN RSA PRIVATE KEY-----');
@ -32,18 +32,18 @@ describe('generateKeyPairAndCertificate', () => {
});
it('should generate certificate with custom lifespan', async () => {
const customDays = 30;
const result = await generateKeyPairAndCertificate(customDays);
const customYears = 30;
const result = await generateKeyPairAndCertificate(customYears);
const expectedNotAfter = addDays(new Date(), customDays);
const expectedNotAfter = addYears(new Date(), customYears);
expect(result.notAfter.getDate()).toBe(expectedNotAfter.getDate());
expect(result.notAfter.getMonth()).toBe(expectedNotAfter.getMonth());
expect(result.notAfter.getFullYear()).toBe(expectedNotAfter.getFullYear());
});
it('should generate unique serial numbers for different certificates', async () => {
const result1 = await generateKeyPairAndCertificate();
const result2 = await generateKeyPairAndCertificate();
const result1 = await generateKeyPairAndCertificate(1);
const result2 = await generateKeyPairAndCertificate(1);
const cert1 = forge.pki.certificateFromPem(result1.certificate);
const cert2 = forge.pki.certificateFromPem(result2.certificate);
@ -52,7 +52,7 @@ describe('generateKeyPairAndCertificate', () => {
});
it('should generate RSA key pair with 4096 bits', async () => {
const result = await generateKeyPairAndCertificate();
const result = await generateKeyPairAndCertificate(1);
const privateKey = forge.pki.privateKeyFromPem(result.privateKey);
// RSA key should be 4096 bits
@ -66,7 +66,7 @@ describe('calculateCertificateFingerprints', () => {
beforeAll(async () => {
// Generate a valid certificate for testing
const { certificate } = await generateKeyPairAndCertificate();
const { certificate } = await generateKeyPairAndCertificate(1);
// eslint-disable-next-line @silverhand/fp/no-mutation
validCertificate = certificate;
});

View file

@ -9,7 +9,7 @@ import {
type CertificateFingerprints,
} from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import { addDays } from 'date-fns';
import { addYears } from 'date-fns';
import forge from 'node-forge';
import { z } from 'zod';
@ -25,15 +25,15 @@ const pemCertificateGuard = z
// Add base64 validation schema
const base64Guard = z.string().regex(/^[\d+/A-Za-z]*={0,2}$/);
export const generateKeyPairAndCertificate = async (lifeSpanInDays = 365) => {
export const generateKeyPairAndCertificate = async (lifeSpanInYears: number) => {
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
return createCertificate(keypair, lifeSpanInDays);
return createCertificate(keypair, lifeSpanInYears);
};
const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInDays: number) => {
const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInYears: number) => {
const cert = forge.pki.createCertificate();
const notBefore = new Date();
const notAfter = addDays(notBefore, lifeSpanInDays);
const notAfter = addYears(notBefore, lifeSpanInYears);
// Can not initialize the certificate with the keypair directly, so we need to set the public key manually.
/* eslint-disable @silverhand/fp/no-mutation */

View file

@ -77,7 +77,13 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
applicationId: application.id,
...config,
}),
createSamlApplicationSecret({ applicationId: application.id, isActive: true }),
// Create a default SAML app secret
createSamlApplicationSecret({
applicationId: application.id,
isActive: true,
// The default lifetime is 3 years
lifeSpanInYears: 3,
}),
]);
ctx.status = 201;
@ -162,17 +168,18 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
'/saml-applications/:id/secrets',
koaGuard({
params: z.object({ id: z.string() }),
body: z.object({ lifeSpanInDays: z.number().optional() }),
// The life span of the SAML app secret is in years (at least 1 year), and for security concern, secrets which never expire are not recommended.
body: z.object({ lifeSpanInYears: z.number().int().gte(1) }),
response: samlApplicationSecretResponseGuard,
status: [201, 400, 404],
}),
async (ctx, next) => {
const {
body: { lifeSpanInDays },
body: { lifeSpanInYears },
params: { id },
} = ctx.guard;
const secret = await createSamlApplicationSecret({ applicationId: id, lifeSpanInDays });
const secret = await createSamlApplicationSecret({ applicationId: id, lifeSpanInYears });
ctx.status = 201;
ctx.body = {
...secret,

View file

@ -28,9 +28,9 @@ export const updateSamlApplication = async (
export const getSamlApplication = async (id: string) =>
authedAdminApi.get(`saml-applications/${id}`).json<SamlApplicationResponse>();
export const createSamlApplicationSecret = async (id: string, lifeSpanInDays: number) =>
export const createSamlApplicationSecret = async (id: string, lifeSpanInYears: number) =>
authedAdminApi
.post(`saml-applications/${id}/secrets`, { json: { lifeSpanInDays } })
.post(`saml-applications/${id}/secrets`, { json: { lifeSpanInYears } })
.json<SamlApplicationSecretResponse>();
export const getSamlApplicationSecrets = async (id: string) =>

View file

@ -180,6 +180,9 @@ const application_details = {
create_new_secret: 'Create new secret',
delete_confirmation:
'This action cannot be undone. Are you sure you want to delete this secret?',
deleted: 'The secret has been successfully deleted.',
activated: 'The secret has been successfully activated.',
deactivated: 'The secret has been successfully deactivated.',
legacy_secret: 'Legacy secret',
expired: 'Expired',
expired_tooltip: 'This secret was expired on {{date}}.',
@ -191,6 +194,8 @@ const application_details = {
'The secret will never expire. We recommend setting an expiration date for enhanced security.',
days: '{{count}} day',
days_other: '{{count}} days',
years: '{{count}} year',
years_other: '{{count}} years',
created: 'The secret {{name}} has been successfully created.',
},
edit_modal: {

View file

@ -29,6 +29,8 @@ const general = {
edit: 'Edit',
delete: 'Delete',
deleted: 'Deleted',
activate: 'Activate',
deactivate: 'Deactivate',
more_options: 'MORE OPTIONS',
close: 'Close',
copy: 'Copy',