Skip to content

Commit

Permalink
Merge pull request #4423 from mzedel/men-7277
Browse files Browse the repository at this point in the history
MEN-7277 - show SSO info depending on configured SSO type
  • Loading branch information
mzedel committed Jun 4, 2024
2 parents 7ea6d54 + ccaae1a commit fb1ac19
Show file tree
Hide file tree
Showing 11 changed files with 72 additions and 61 deletions.
13 changes: 6 additions & 7 deletions src/js/actions/organizationActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import {
RECEIVE_SSO_CONFIGS,
RECEIVE_WEBHOOK_EVENTS,
SET_AUDITLOG_STATE,
SET_ORGANIZATION
SET_ORGANIZATION,
SSO_TYPES
} from '../constants/organizationConstants';
import { deepCompare, getSsoByContentType } from '../helpers';
import { deepCompare } from '../helpers';
import { getCurrentSession, getTenantCapabilities } from '../selectors';
import { commonErrorFallback, commonErrorHandler, setFirstLoginAfterSignup, setSnackbar } from './appActions';
import { deviceAuthV2, iotManagerBaseURL } from './deviceActions';
Expand Down Expand Up @@ -319,17 +320,15 @@ const getSsoConfigById = config => dispatch =>
Api.get(`${ssoIdpApiUrlv1}/${config.id}`)
.catch(err => dispatch(ssoConfigActionErrorHandler(err, 'read')))
.then(({ data, headers }) => {
const sso = getSsoByContentType(headers['content-type']);
return sso ? Promise.resolve({ ...config, config: data, type: sso.id }) : Promise.reject('Not supported SSO config content type.');
const sso = Object.values(SSO_TYPES).find(({ contentType }) => contentType === headers['content-type']);
return sso ? Promise.resolve({ ...config, config: data, type: sso.id }) : Promise.reject('Unsupported SSO config content type.');
});

export const getSsoConfigs = () => dispatch =>
Api.get(ssoIdpApiUrlv1)
.catch(err => commonErrorHandler(err, 'There was an error retrieving SSO configurations', dispatch, commonErrorFallback))
.then(({ data }) =>
Promise.all(data.map(config => Promise.resolve(dispatch(getSsoConfigById(config)))))
.then(configs => {
return dispatch({ type: RECEIVE_SSO_CONFIGS, value: configs });
})
.then(configs => dispatch({ type: RECEIVE_SSO_CONFIGS, value: configs }))
.catch(err => commonErrorHandler(err, err, dispatch, ''))
);
7 changes: 4 additions & 3 deletions src/js/actions/userActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import GeneralApi, { apiRoot } from '../api/general-api';
import UsersApi from '../api/users-api';
import { cleanUp, maxSessionAge, setSessionInfo } from '../auth';
import { HELPTOOLTIPS } from '../components/helptips/helptooltips';
import { getSsoStartUrlById } from '../components/settings/organization/ssoconfig.js';
import * as AppConstants from '../constants/appConstants';
import { APPLICATION_JSON_CONTENT_TYPE, APPLICATION_JWT_CONTENT_TYPE } from '../constants/appConstants';
import { SSO_TYPES } from '../constants/organizationConstants.js';
import { ALL_RELEASES } from '../constants/releaseConstants.js';
import * as UserConstants from '../constants/userConstants';
import { duplicateFilter, extractErrorMessage, isEmpty, preformatWithRequestID } from '../helpers';
Expand Down Expand Up @@ -81,8 +81,9 @@ export const loginUser = (userData, stayLoggedIn) => dispatch =>
// If the content type is application/json then backend returned SSO configuration.
// user should be redirected to the start sso url to finish login process.
if (contentType.includes(APPLICATION_JSON_CONTENT_TYPE)) {
const { id } = response;
const ssoLoginUrl = getSsoStartUrlById(id);
const { id, kind } = response;
const type = kind.split('/')[1];
const ssoLoginUrl = SSO_TYPES[type].getStartUrl(id);
window.location.replace(ssoLoginUrl);
return;
}
Expand Down
5 changes: 2 additions & 3 deletions src/js/actions/userActions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import Cookies from 'universal-cookie';
import { inventoryDevice } from '../../../tests/__mocks__/deviceHandlers';
import { accessTokens, defaultPassword, defaultState, receivedPermissionSets, receivedRoles, testSsoId, token, userId } from '../../../tests/mockData';
import { HELPTOOLTIPS } from '../components/helptips/helptooltips';
import { getSsoStartUrlById } from '../components/settings/organization/ssoconfig.js';
import {
SET_ANNOUNCEMENT,
SET_ENVIRONMENT_DATA,
Expand Down Expand Up @@ -54,7 +53,7 @@ import {
UNGROUPED_GROUP
} from '../constants/deviceConstants';
import { SET_DEMO_ARTIFACT_PORT, SET_ONBOARDING_COMPLETE } from '../constants/onboardingConstants';
import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS } from '../constants/organizationConstants';
import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, getSamlStartUrl } from '../constants/organizationConstants';
import { RECEIVE_RELEASES, SET_RELEASES_LIST_STATE } from '../constants/releaseConstants';
import {
CREATED_ROLE,
Expand Down Expand Up @@ -507,7 +506,7 @@ describe('user actions', () => {
jest.runOnlyPendingTimers();
jest.runAllTicks();
});
expect(replaceSpy).toHaveBeenCalledWith(getSsoStartUrlById(testSsoId));
expect(replaceSpy).toHaveBeenCalledWith(getSamlStartUrl(testSsoId));
});
it('should prevent logging in with a limited user', async () => {
jest.clearAllMocks();
Expand Down
23 changes: 11 additions & 12 deletions src/js/components/settings/organization/organization.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from '../../../actions/organizationActions';
import { TIMEOUTS } from '../../../constants/appConstants';
import { SSO_TYPES } from '../../../constants/organizationConstants.js';
import { createFileDownload, getSsoByType, toggle } from '../../../helpers';
import { createFileDownload, toggle } from '../../../helpers';
import { getCurrentSession, getFeatures, getIsEnterprise, getIsPreview, getOrganization, getSsoConfig, getUserRoles } from '../../../selectors';
import ExpandableAttribute from '../../common/expandable-attribute';
import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips';
Expand Down Expand Up @@ -87,7 +87,7 @@ export const Organization = () => {
const [isConfiguringSSO, setIsConfiguringSSO] = useState(false);
const [isResettingSSO, setIsResettingSSO] = useState(false);
const [showTokenWarning, setShowTokenWarning] = useState(false);
const [newSso, setNewSso] = useState(undefined);
const [newSso, setNewSso] = useState('');
const [selectedSsoItem, setSelectedSsoItem] = useState(undefined);
const isEnterprise = useSelector(getIsEnterprise);
const { isAdmin } = useSelector(getUserRoles);
Expand All @@ -111,15 +111,15 @@ export const Organization = () => {
setHasSingleSignOn(!!ssoConfig);
setIsConfiguringSSO(!!ssoConfig);
if (ssoConfig) {
setSelectedSsoItem(getSsoByType(ssoConfig.type));
setSelectedSsoItem(SSO_TYPES[ssoConfig.type]);
}
}, [ssoConfig]);

const dispatchedSetSnackbar = useCallback((...args) => dispatch(setSnackbar(...args)), [dispatch]);

const onSaveSSOSettings = useCallback(
(id, config) => {
const { contentType } = getSsoByType(selectedSsoItem.id);
const { contentType } = SSO_TYPES[selectedSsoItem.type];
if (isResettingSSO) {
return dispatch(deleteSsoConfig(ssoConfig)).then(() => setIsResettingSSO(false));
}
Expand Down Expand Up @@ -159,19 +159,18 @@ export const Organization = () => {
if (ssoConfig) {
setNewSso(type);
} else {
setSelectedSsoItem(getSsoByType(type));
setSelectedSsoItem(SSO_TYPES[type]);
}
},
[ssoConfig]
);

const changeSSO = () => {
const changeSSO = () =>
dispatch(deleteSsoConfig(ssoConfig)).then(() => {
setSelectedSsoItem(getSsoByType(newSso));
setSelectedSsoItem(SSO_TYPES[newSso]);
setIsConfiguringSSO(true);
setNewSso(undefined);
setNewSso('');
});
};

return (
<div className="margin-top-small">
Expand Down Expand Up @@ -230,10 +229,10 @@ export const Organization = () => {

{isConfiguringSSO && (
<div>
<Select className={classes.ssoSelect} displayEmpty onChange={onSsoSelect} value={selectedSsoItem?.id || ''}>
<Select className={classes.ssoSelect} displayEmpty onChange={onSsoSelect} value={selectedSsoItem?.type || ''}>
<MenuItem value="">Select type</MenuItem>
{SSO_TYPES.map(item => (
<MenuItem key={item.id} value={item.id}>
{Object.values(SSO_TYPES).map(item => (
<MenuItem key={item.type} value={item.type}>
<div className="capitalized-start">{item.title}</div>
</MenuItem>
))}
Expand Down
19 changes: 7 additions & 12 deletions src/js/components/settings/organization/organization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,25 @@ describe('MyOrganization Component', () => {
const ui = <MyOrganization />;
const { rerender } = render(ui, { preloadedState: { ...preloadedState, users: { ...preloadedState.users, currentSession: getSessionInfo() } } });
await waitFor(() => rerender(ui));
await act(async () => {});
await act(async () => {
jest.runOnlyPendingTimers();
jest.runAllTicks();
});
expect(screen.getByText(/text editor/i)).toBeVisible();
await user.click(screen.getByText(/text editor/i));
await waitFor(() => rerender(ui));
expect(screen.getByText(/import from a file/i)).toBeVisible();
await user.upload(screen.getByText(/import from a file/i).previousSibling, file);
await waitFor(() => expect(document.querySelector(`.${drawerClasses.root}`)).toBeVisible());
await act(async () => {
await user.click(screen.getByTestId('CloseIcon'));
});
await user.click(screen.getByTestId('CloseIcon'));
await waitFor(() => rerender(ui));
await waitFor(() => expect(document.querySelector(`.${drawerClasses.root}`)).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('checkbox')).toBeChecked());
while (screen.queryByText(/entity id/i)) {
await act(async () => {
await user.click(screen.getByRole('checkbox'));
await user.click(screen.getByRole('button', { name: /save/i }));
});
await user.click(screen.getByRole('checkbox'));
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => rerender(ui));
}
const input = document.querySelector('input[type=file]');
await user.upload(input, file);
await waitFor(() => rerender(ui));
expect(document.querySelector(`.${drawerClasses.root}`)).not.toBeInTheDocument();
});
});

Expand Down
17 changes: 4 additions & 13 deletions src/js/components/settings/organization/ssoconfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ import { Button } from '@mui/material';
import { listItemTextClasses } from '@mui/material/ListItemText';
import { makeStyles } from 'tss-react/mui';

import { XML_METADATA_FORMAT } from '../../../constants/organizationConstants.js';
import { useradmApiUrl } from '../../../constants/userConstants';
import { SSO_TYPES, XML_METADATA_FORMAT } from '../../../constants/organizationConstants.js';
import { toggle } from '../../../helpers';
import ExpandableAttribute from '../../common/expandable-attribute';
import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips';
Expand Down Expand Up @@ -56,20 +55,12 @@ const useStyles = makeStyles()(theme => ({
}
}));

export const getSsoStartUrlById = id => `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/login`;

const defaultDetails = [
{ key: 'entityID', label: 'Entity ID', getValue: id => `${window.location.origin}${useradmApiUrl}/sso/sp/metadata/${id}` },
{ key: 'acs', label: 'ACS URL', getValue: id => `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/acs` },
{ key: 'startURL', label: 'Start URL', getValue: id => getSsoStartUrlById(id) }
];

export const SSOConfig = ({ ssoItem, config, onCancel, onSave, setSnackbar, token }) => {
const [configDetails, setConfigDetails] = useState([]);
const [fileContent, setFileContentState] = useState('');
const [hasSSOConfig, setHasSSOConfig] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const { id, ...content } = config || {};
const { id, type, ...content } = config || {};
const configContent = content.config || '';

// file content should be text, otherwise editor will fail
Expand All @@ -81,9 +72,9 @@ export const SSOConfig = ({ ssoItem, config, onCancel, onSave, setSnackbar, toke
setHasSSOConfig(!!config);
setFileContent(configContent);
if (config?.id) {
setConfigDetails(defaultDetails.map(item => ({ ...item, value: item.getValue(config.id) })));
setConfigDetails(SSO_TYPES[type].configDetails.map(item => ({ ...item, value: item.getValue(config.id) })));
}
}, [config, configContent]);
}, [config, configContent, type]);

const onCancelSSOSettings = () => {
setHasSSOConfig(!!config);
Expand Down
7 changes: 6 additions & 1 deletion src/js/components/settings/organization/ssoconfig.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import SSOConfig from './ssoconfig';
describe('SamlConfig Component', () => {
it('renders correctly', async () => {
const { baseElement } = render(
<SSOConfig ssoItem={SSO_TYPES[0]} config={{ id: '1', config: `<div>not quite right</div>` }} onCancel={jest.fn} onSave={jest.fn} />
<SSOConfig
ssoItem={SSO_TYPES.saml}
config={{ id: '1', config: `<div>not quite right</div>`, type: SSO_TYPES.saml.type }}
onCancel={jest.fn}
onSave={jest.fn}
/>
);
const view = baseElement.firstChild;
expect(view).toMatchSnapshot();
Expand Down
2 changes: 1 addition & 1 deletion src/js/components/settings/organization/ssoeditor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('SSOEditor Component', () => {
const config = '<div>not quite right</div>';
const { baseElement } = render(
<SSOEditor
ssoItem={SSO_TYPES[0]}
ssoItem={SSO_TYPES.saml}
config={config}
onCancel={jest.fn}
onSave={jest.fn}
Expand Down
34 changes: 30 additions & 4 deletions src/js/constants/organizationConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,40 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { EXTERNAL_PROVIDER } from './deviceConstants';
import { useradmApiUrl } from './userConstants';

export const XML_METADATA_FORMAT = 'xml';
export const JSON_METADATA_FORMAT = 'json';

export const SSO_TYPES = [
{ id: 'saml', title: 'SAML', metadataFormat: XML_METADATA_FORMAT, editorLanguage: XML_METADATA_FORMAT, contentType: 'application/samlmetadata+xml' },
{ id: 'oidc', title: 'OpenID Connect', metadataFormat: JSON_METADATA_FORMAT, editorLanguage: JSON_METADATA_FORMAT, contentType: 'application/json' }
];
export const getSamlStartUrl = id => `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/login`;
export const getOidcStartUrl = id => `${window.location.origin}${useradmApiUrl}/oidc/${id}/start`;

export const SSO_TYPES = {
saml: {
id: 'saml',
type: 'saml',
title: 'SAML',
metadataFormat: XML_METADATA_FORMAT,
editorLanguage: XML_METADATA_FORMAT,
contentType: 'application/samlmetadata+xml',
getStartUrl: getSamlStartUrl,
configDetails: [
{ key: 'entityID', label: 'Entity ID', getValue: id => `${window.location.origin}${useradmApiUrl}/sso/sp/metadata/${id}` },
{ key: 'acs', label: 'ACS URL', getValue: id => `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/acs` },
{ key: 'startURL', label: 'Start URL', getValue: getSamlStartUrl }
]
},
oidc: {
id: 'oidc',
type: 'oidc',
title: 'OpenID Connect',
metadataFormat: JSON_METADATA_FORMAT,
editorLanguage: JSON_METADATA_FORMAT,
contentType: 'application/json',
getStartUrl: getOidcStartUrl,
configDetails: [{ key: 'startURL', label: 'Start Url', getValue: getOidcStartUrl }]
}
};

export const AUDIT_LOGS_TYPES = [
{ title: 'Artifact', queryParameter: 'object_type', value: 'artifact' },
Expand Down
4 changes: 0 additions & 4 deletions src/js/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
deploymentStatesToSubstatesWithSkipped
} from './constants/deploymentConstants';
import { ATTRIBUTE_SCOPES, DEVICE_FILTERING_OPTIONS } from './constants/deviceConstants';
import { SSO_TYPES } from './constants/organizationConstants.js';

const isEncoded = uri => {
uri = uri || '';
Expand Down Expand Up @@ -530,6 +529,3 @@ export const getISOStringBoundaries = currentDate => {
};

export const isDarkMode = mode => mode === DARK_MODE;

export const getSsoByType = type => SSO_TYPES.find(item => item.id === type);
export const getSsoByContentType = contentType => SSO_TYPES.find(item => item.contentType === contentType);
2 changes: 1 addition & 1 deletion tests/__mocks__/userHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const userHandlers = [
const [user, password] = authInfo.split(':');

if (!password) {
return HttpResponse.json({ id: testSsoId });
return HttpResponse.json({ id: testSsoId, kind: 'sso/saml' });
} else if (password !== defaultPassword) {
return new HttpResponse(null, { status: 401 });
} else if (user.includes('limited')) {
Expand Down

0 comments on commit fb1ac19

Please sign in to comment.