Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fruity-views-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/rest-typings': patch
'@rocket.chat/i18n': patch
'@rocket.chat/meteor': patch
---

Adds an Import IdP metadata option to SAML settings that fetches the Identity Provider metadata from a URL and prefills the matching setting fields — certificate, entry point and IDP SLO redirect URL, plus identifier format on Enterprise — for the admin to review before saving.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import BaseGroupPage from '../groups/BaseGroupPage';
import EnterpriseGroupPage from '../groups/EnterpriseGroupPage';
import LDAPGroupPage from '../groups/LDAPGroupPage';
import OAuthGroupPage from '../groups/OAuthGroupPage';
import SAMLGroupPage from '../groups/SAMLGroupPage';

export type SettingsGroupSelectorProps = {
groupId: ISetting['_id'];
Expand All @@ -27,6 +28,10 @@ const SettingsGroupSelector = ({ groupId, onClickBack }: SettingsGroupSelectorPr
return <LDAPGroupPage {...group} onClickBack={onClickBack} />;
}

if (groupId === 'SAML') {
return <SAMLGroupPage {...group} onClickBack={onClickBack} />;
}

if (groupId === 'Assets') {
return <BaseGroupPage {...group} onClickBack={onClickBack} hasReset={false} />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { Button } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useSetModal, useToastMessageDispatch, useSettingStructure } from '@rocket.chat/ui-contexts';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import SamlMetadataModal from './SamlMetadataModal';
import type { SamlMetadataValues } from './SamlMetadataModal';
import { useEditableSettings, useEditableSettingsDispatch } from '../../../EditableSettingsContext';
import BaseGroupPage from '../BaseGroupPage';

type SAMLGroupPageProps = ISetting & {
onClickBack?: () => void;
};

function SAMLGroupPage({ _id, i18nLabel, onClickBack, ...group }: SAMLGroupPageProps) {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const parseMetadata = useEndpoint('POST', '/v1/saml.parseMetadata');
const setModal = useSetModal();
const closeModal = useStableCallback(() => setModal());
const dispatch = useEditableSettingsDispatch();

const certSetting = useSettingStructure('SAML_Custom_Default_cert');
const entryPointSetting = useSettingStructure('SAML_Custom_Default_entry_point');
const sloSetting = useSettingStructure('SAML_Custom_Default_idp_slo_redirect_url');
const identifierFormatSetting = useSettingStructure('SAML_Custom_Default_identifier_format');

const editableSettings = useEditableSettings(useMemo(() => ({ group: _id }), [_id]));
const changed = useMemo(() => editableSettings.some(({ changed }) => changed), [editableSettings]);

const handleApply = useStableCallback((values: SamlMetadataValues) => {
// identifier_format is only registered on Enterprise installs; skip any setting that isn't present.
const add = (setting: ISetting | undefined, value?: string) =>
setting && value !== undefined ? [{ _id: setting._id, value, changed: JSON.stringify(setting.value) !== JSON.stringify(value) }] : [];

const changes = [
...add(certSetting, values.cert),
...add(entryPointSetting, values.entryPoint),
...add(sloSetting, values.idpSLORedirectURL),
...add(identifierFormatSetting, values.identifierFormat),
];

dispatch(changes);
closeModal();

if (changes.length === 0) {
dispatchToastMessage({ type: 'warning', message: t('SAML_Metadata_no_values') });
return;
}

dispatchToastMessage({ type: 'success', message: t('SAML_Metadata_applied') });
});

const handleImportClick = () =>
setModal(
<SamlMetadataModal
onClose={closeModal}
onFetch={(url) => parseMetadata({ url })}
onApply={handleApply}
showIdentifierFormat={identifierFormatSetting !== undefined}
/>,
);

return (
<BaseGroupPage
_id={_id}
i18nLabel={i18nLabel}
onClickBack={onClickBack}
{...group}
headerButtons={
Comment thread
ricardogarim marked this conversation as resolved.
<Button disabled={changed} onClick={handleImportClick}>
{t('SAML_Import_metadata')}
</Button>
}
/>
);
}

export default memo(SAMLGroupPage);
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import SamlMetadataModal from './SamlMetadataModal';

const setup = (overrides: Partial<Parameters<typeof SamlMetadataModal>[0]> = {}) => {
const props = {
onClose: jest.fn(),
onFetch: jest.fn().mockResolvedValue({
cert: 'CERTDATA',
entryPoint: 'https://idp.test/sso',
idpSLORedirectURL: 'https://idp.test/slo',
warnings: [],
}),
onApply: jest.fn(),
...overrides,
};
render(<SamlMetadataModal {...props} />, { wrapper: mockAppRoot().build() });
return props;
};

it('fetches metadata and shows the preview, then applies edited values', async () => {
const props = setup();

await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));

await waitFor(() => expect(props.onFetch).toHaveBeenCalledWith('https://idp.test/metadata.xml'));

const entryPointInput = await screen.findByLabelText('SAML_Custom_Entry_point');
expect(entryPointInput).toHaveValue('https://idp.test/sso');
expect(screen.getByLabelText('SAML_Custom_Cert')).toHaveValue('CERTDATA');
expect(screen.getByLabelText('SAML_Custom_IDP_SLO_Redirect_URL')).toHaveValue('https://idp.test/slo');

await userEvent.clear(entryPointInput);
await userEvent.type(entryPointInput, 'https://idp.test/sso-edited');
await userEvent.click(screen.getByText('Apply'));

expect(props.onApply).toHaveBeenCalledWith({
cert: 'CERTDATA',
entryPoint: 'https://idp.test/sso-edited',
idpSLORedirectURL: 'https://idp.test/slo',
});
});

it('shows a warning callout when the fetch result carries warnings', async () => {
setup({
onFetch: jest.fn().mockResolvedValue({ cert: 'CERTDATA', warnings: ['SAML_Metadata_warning_multiple_certs'] }),
});

await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));

expect(await screen.findByText('SAML_Metadata_warning_multiple_certs')).toBeInTheDocument();
});

it('returns to Fetch mode when the URL is edited after a successful fetch', async () => {
setup();

await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));

expect(await screen.findByText('Apply')).toBeInTheDocument();

await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), '2');

expect(await screen.findByText('SAML_Metadata_fetch')).toBeInTheDocument();
expect(screen.queryByText('Apply')).not.toBeInTheDocument();
});

it('shows the Identifier Format row only when showIdentifierFormat is true', async () => {
setup({
showIdentifierFormat: true,
onFetch: jest.fn().mockResolvedValue({
cert: 'CERTDATA',
entryPoint: 'https://idp.test/sso',
idpSLORedirectURL: 'https://idp.test/slo',
identifierFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient',
warnings: [],
}),
});

await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));

expect(await screen.findByLabelText('SAML_Identifier_Format')).toHaveValue('urn:oasis:names:tc:SAML:2.0:nameid-format:transient');
});

it('does not show the Identifier Format row when showIdentifierFormat is false', async () => {
setup();

await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));

expect(await screen.findByLabelText('SAML_Custom_Cert')).toBeInTheDocument();
expect(screen.queryByLabelText('SAML_Identifier_Format')).not.toBeInTheDocument();
});

it('calls onClose when Cancel is clicked', async () => {
const props = setup();
await userEvent.click(screen.getByText('Cancel'));
expect(props.onClose).toHaveBeenCalled();
});

it('shows a danger callout and allows retry when fetch fails', async () => {
setup({
onFetch: jest.fn().mockRejectedValueOnce({ success: false, error: 'SAML_Metadata_fetch_failed' }),
});

await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));

expect(await screen.findByText('SAML_Metadata_fetch_failed')).toBeInTheDocument();

const confirmButton = screen.getByText('SAML_Metadata_fetch');
expect(confirmButton).toBeInTheDocument();
expect(confirmButton).not.toBeDisabled();

const urlInput = screen.getByLabelText('SAML_Metadata_url') as HTMLInputElement;
expect(urlInput.value).toBe('https://idp.test/metadata.xml');
});
Loading
Loading