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:
parent
3f8f8c626b
commit
580ed25ad7
14 changed files with 384 additions and 50 deletions
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
.icon {
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -29,6 +29,8 @@ const general = {
|
|||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
deleted: 'Deleted',
|
||||
activate: 'Activate',
|
||||
deactivate: 'Deactivate',
|
||||
more_options: 'MORE OPTIONS',
|
||||
close: 'Close',
|
||||
copy: 'Copy',
|
||||
|
|
Loading…
Add table
Reference in a new issue