From ebf889c7072b359bbaeffad16a4920ce0d2819e5 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sun, 22 Mar 2026 10:22:18 -0400 Subject: [PATCH] Add support for selecting sign in options dynamically from authentication tab Signed-off-by: Craig Perkins --- common/index.ts | 1 + .../panels/auth-view/auth-view.tsx | 28 +++ .../auth-view/dashboard-signin-options.tsx | 188 ++++++++++++++++++ .../panels/auth-view/signin-options-modal.tsx | 124 ++++++++++++ .../panels/auth-view/test/auth-view.test.tsx | 5 + .../test/dashboard-signin-options.test.tsx | 128 ++++++++++++ public/apps/configuration/types.ts | 13 ++ public/apps/login/login-page.tsx | 105 ++++++++-- .../__snapshots__/login-page.test.tsx.snap | 68 ++++--- public/apps/login/test/login-page.test.tsx | 54 +++-- public/types.ts | 1 + public/utils/dashboards-info-utils.tsx | 23 ++- server/routes/index.ts | 96 ++++++++- test/cypress/e2e/oidc/oidc_auth_test.spec.js | 3 +- test/cypress/e2e/saml/saml_auth_test.spec.js | 3 +- .../security_entity_api.test.ts | 29 +++ 16 files changed, 802 insertions(+), 67 deletions(-) create mode 100644 public/apps/configuration/panels/auth-view/dashboard-signin-options.tsx create mode 100644 public/apps/configuration/panels/auth-view/signin-options-modal.tsx create mode 100644 public/apps/configuration/panels/auth-view/test/dashboard-signin-options.test.tsx diff --git a/common/index.ts b/common/index.ts index 7bfd03be3..2a7af09ba 100644 --- a/common/index.ts +++ b/common/index.ts @@ -33,6 +33,7 @@ export const API_PREFIX = '/api/v1'; export const CONFIGURATION_API_PREFIX = 'configuration'; export const API_ENDPOINT_AUTHINFO = API_PREFIX + '/auth/authinfo'; export const API_ENDPOINT_DASHBOARDSINFO = API_PREFIX + '/auth/dashboardsinfo'; +export const API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS = API_ENDPOINT_DASHBOARDSINFO + '/signinoptions'; export const API_ENDPOINT_AUTHTYPE = API_PREFIX + '/auth/type'; export const LOGIN_PAGE_URI = '/app/' + APP_ID_LOGIN; export const CUSTOM_ERROR_PAGE_URI = '/app/' + APP_ID_CUSTOMERROR; diff --git a/public/apps/configuration/panels/auth-view/auth-view.tsx b/public/apps/configuration/panels/auth-view/auth-view.tsx index 2b39851aa..ea9a07d1a 100644 --- a/public/apps/configuration/panels/auth-view/auth-view.tsx +++ b/public/apps/configuration/panels/auth-view/auth-view.tsx @@ -28,10 +28,17 @@ import { SecurityPluginTopNavMenu } from '../../top-nav-menu'; import { AccessErrorComponent } from '../../access-error-component'; import { PageHeader } from '../../header/header-components'; import { ResourceType } from '../../../../../common'; +import { getDashboardsSignInOptions } from '../../../../utils/dashboards-info-utils'; +import { DashboardSignInOption } from '../../types'; +import { SignInOptionsPanel } from './dashboard-signin-options'; +import { LocalCluster } from '../../../../utils/datasource-utils'; export function AuthView(props: AppDependencies) { const [authentication, setAuthentication] = React.useState([]); const [authorization, setAuthorization] = React.useState([]); + const [dashboardSignInOptions, setDashboardSignInOptions] = React.useState< + DashboardSignInOption[] + >([]); const [loading, setLoading] = React.useState(false); const { dataSource, setDataSource } = useContext(DataSourceContext)!; const [errorFlag, setErrorFlag] = React.useState(false); @@ -45,6 +52,15 @@ export function AuthView(props: AppDependencies) { setAuthentication(config.authc); setAuthorization(config.authz); + if (dataSource.id === LocalCluster.id) { + try { + setDashboardSignInOptions(await getDashboardsSignInOptions(props.coreStart.http)); + } catch (error) { + setDashboardSignInOptions([]); + } + } else { + setDashboardSignInOptions([]); + } setErrorFlag(false); setAccessErrorFlag(false); } catch (e) { @@ -139,6 +155,18 @@ export function AuthView(props: AppDependencies) { <> {/* @ts-ignore */} + {dataSource.id === LocalCluster.id && ( + <> + + {/* @ts-ignore */} + + + )} {/* @ts-ignore */} diff --git a/public/apps/configuration/panels/auth-view/dashboard-signin-options.tsx b/public/apps/configuration/panels/auth-view/dashboard-signin-options.tsx new file mode 100644 index 000000000..a84bbacc6 --- /dev/null +++ b/public/apps/configuration/panels/auth-view/dashboard-signin-options.tsx @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { + EuiBasicTableColumn, + EuiFlexGroup, + EuiGlobalToastList, + EuiHealth, + EuiHorizontalRule, + EuiInMemoryTable, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { get, keys } from 'lodash'; +import { HttpStart } from 'opensearch-dashboards/public'; +import React from 'react'; +import { updateDashboardSignInOptions } from '../../../../utils/dashboards-info-utils'; +import { DashboardOption, DashboardSignInOption } from '../../types'; +import { createErrorToast, createSuccessToast, useToastState } from '../../utils/toast-utils'; +import { SignInOptionsModal } from './signin-options-modal'; + +interface SignInOptionsPanelProps { + authc: []; + signInEnabledOptions: DashboardSignInOption[]; + http: HttpStart; + isAnonymousAuthEnabled: boolean; +} + +const OPTION_LABELS: Record = { + [DashboardSignInOption.BASIC]: 'Basic authentication', + [DashboardSignInOption.OPEN_ID]: 'OpenID Connect', + [DashboardSignInOption.SAML]: 'SAML', + [DashboardSignInOption.ANONYMOUS]: 'Anonymous', +}; + +export const columns: Array> = [ + { + field: 'displayName', + name: 'Name', + dataType: 'string', + sortable: true, + }, + { + field: 'status', + name: 'Status', + render: (enabled: DashboardOption['status']) => ( + + {enabled ? 'Enabled' : 'Disabled'} + + ), + }, +]; + +function getDashboardOptions( + authc: [], + enabledOptions: DashboardSignInOption[], + isAnonymousAuthEnabled: boolean +) { + const options = keys(authc) + .map((domain) => get(authc, [domain, 'http_authenticator', 'type'])) + .filter((option): option is string => Boolean(option)) + .map((option) => { + switch (option.toLowerCase()) { + case 'basic': + case DashboardSignInOption.BASIC: + return DashboardSignInOption.BASIC; + case DashboardSignInOption.OPEN_ID: + return DashboardSignInOption.OPEN_ID; + case DashboardSignInOption.SAML: + return DashboardSignInOption.SAML; + default: + return undefined; + } + }) + .filter((option): option is DashboardSignInOption => Boolean(option)) + .filter((option): option is DashboardSignInOption => + [ + DashboardSignInOption.BASIC, + DashboardSignInOption.OPEN_ID, + DashboardSignInOption.SAML, + ].includes(option as DashboardSignInOption) + ) + .filter((option, index, arr) => arr.indexOf(option) === index) + .map((option) => ({ + name: option, + displayName: OPTION_LABELS[option], + status: enabledOptions.includes(option), + })); + + if (isAnonymousAuthEnabled) { + options.push({ + name: DashboardSignInOption.ANONYMOUS, + displayName: OPTION_LABELS[DashboardSignInOption.ANONYMOUS], + status: enabledOptions.includes(DashboardSignInOption.ANONYMOUS), + }); + } + + return options.sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +export function SignInOptionsPanel(props: SignInOptionsPanelProps) { + const [toasts, addToast, removeToast] = useToastState(); + const [dashboardOptions, setDashboardOptions] = React.useState(() => + getDashboardOptions(props.authc, props.signInEnabledOptions, props.isAnonymousAuthEnabled) + ); + + React.useEffect(() => { + setDashboardOptions( + getDashboardOptions(props.authc, props.signInEnabledOptions, props.isAnonymousAuthEnabled) + ); + }, [props.authc, props.signInEnabledOptions, props.isAnonymousAuthEnabled]); + + const handleUpdate = async (selectedOptions: DashboardOption[]) => { + const selectedNames = selectedOptions.map((option) => option.name); + + try { + await updateDashboardSignInOptions(props.http, selectedNames); + setDashboardOptions((currentOptions) => + currentOptions.map((option) => ({ + ...option, + status: selectedNames.includes(option.name), + })) + ); + addToast( + createSuccessToast( + 'dashboard-signin-options-success', + 'Dashboards sign-in options updated', + 'Changes applied.' + ) + ); + } catch (error) { + addToast( + createErrorToast( + 'dashboard-signin-options-error', + 'Dashboards sign-in options not updated', + error instanceof Error ? error.message : 'Error updating values.' + ) + ); + } + }; + + return ( + + + + +

Dashboards sign-in options

+
+ +

+ Choose which configured authentication methods appear on the Dashboards login page. +

+
+
+ + + + + +
+ + + +
+ ); +} diff --git a/public/apps/configuration/panels/auth-view/signin-options-modal.tsx b/public/apps/configuration/panels/auth-view/signin-options-modal.tsx new file mode 100644 index 000000000..e08d47c18 --- /dev/null +++ b/public/apps/configuration/panels/auth-view/signin-options-modal.tsx @@ -0,0 +1,124 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { + EuiButton, + EuiCallOut, + EuiInMemoryTable, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, +} from '@elastic/eui'; +import React from 'react'; +import { DashboardOption } from '../../types'; +import { columns } from './dashboard-signin-options'; + +interface SignInOptionsModalProps { + dashboardOptions: DashboardOption[]; + handleUpdate: (selectedOptions: DashboardOption[]) => Promise; +} + +function haveSameSelection(left: DashboardOption[], right: DashboardOption[]) { + if (left.length !== right.length) { + return false; + } + + return left.every((option) => + right.some((selectedOption) => selectedOption.name === option.name) + ); +} + +export function SignInOptionsModal(props: SignInOptionsModalProps) { + const selectedOptions = React.useMemo( + () => props.dashboardOptions.filter((option) => option.status), + [props.dashboardOptions] + ); + const [isModalVisible, setIsModalVisible] = React.useState(false); + const [newSignInOptions, setNewSignInOptions] = React.useState( + selectedOptions + ); + + React.useEffect(() => { + if (!isModalVisible) { + setNewSignInOptions(selectedOptions); + } + }, [isModalVisible, selectedOptions]); + + const disableUpdate = haveSameSelection( + newSignInOptions, + props.dashboardOptions.filter((option) => option.status) + ); + + return ( + <> + setIsModalVisible(true)} + > + Edit + + {isModalVisible && ( + setIsModalVisible(false)}> + + Dashboards sign-in options + + + Select which configured authentication methods appear on the Dashboards login page. + + {newSignInOptions.length === 0 && ( + <> + + + + )} + + + + setIsModalVisible(false)}>Cancel + { + await props.handleUpdate(newSignInOptions); + setIsModalVisible(false); + }} + > + Update + + + + )} + + ); +} diff --git a/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx b/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx index 91d020da3..5542f77ba 100644 --- a/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx +++ b/public/apps/configuration/panels/auth-view/test/auth-view.test.tsx @@ -24,6 +24,8 @@ jest.mock('react', () => ({ // eslint-disable-next-line const mockAuthViewUtils = require('../../../utils/auth-view-utils'); +// eslint-disable-next-line +const mockDashboardsInfoUtils = require('../../../../../utils/dashboards-info-utils'); describe('Auth view', () => { const mockCoreStart = { @@ -53,6 +55,7 @@ describe('Auth view', () => { .spyOn(React, 'useState') .mockImplementationOnce(() => [[], setState]) .mockImplementationOnce(() => [[], setState]) + .mockImplementationOnce(() => [[], setState]) .mockImplementationOnce(() => [false, jest.fn()]) .mockImplementationOnce(() => [false, jest.fn()]) .mockImplementationOnce(() => [false, jest.fn()]); @@ -61,6 +64,7 @@ describe('Auth view', () => { it('valid data', (done) => { mockAuthViewUtils.getSecurityConfig = jest.fn().mockReturnValue(config); + mockDashboardsInfoUtils.getDashboardsSignInOptions = jest.fn().mockResolvedValue([]); shallow(); @@ -98,6 +102,7 @@ describe('Auth view', () => { .spyOn(React, 'useState') .mockImplementationOnce(() => [[], setState]) .mockImplementationOnce(() => [[], setState]) + .mockImplementationOnce(() => [[], setState]) .mockImplementationOnce(() => [false, jest.fn()]) .mockImplementationOnce(() => [false, jest.fn()]) .mockImplementationOnce(() => [true, jest.fn()]); diff --git a/public/apps/configuration/panels/auth-view/test/dashboard-signin-options.test.tsx b/public/apps/configuration/panels/auth-view/test/dashboard-signin-options.test.tsx new file mode 100644 index 000000000..30cd3ff7a --- /dev/null +++ b/public/apps/configuration/panels/auth-view/test/dashboard-signin-options.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { SignInOptionsPanel } from '../dashboard-signin-options'; +import { SignInOptionsModal } from '../signin-options-modal'; +import { DashboardSignInOption } from '../../../types'; +import { updateDashboardSignInOptions } from '../../../../../utils/dashboards-info-utils'; + +jest.mock('../../../../../utils/dashboards-info-utils', () => ({ + updateDashboardSignInOptions: jest.fn(), +})); + +describe('SignInOptionsPanel', () => { + const authc = { + basic_auth_domain: { + http_authenticator: { + type: 'basic', + }, + }, + saml_auth_domain: { + http_authenticator: { + type: 'saml', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('builds table items from auth domains and enabled options', () => { + const component = shallow( + + ); + + const items = component.find('EuiInMemoryTable').prop('items') as Array<{ name: string }>; + expect(items.map((item) => item.name)).toEqual([ + DashboardSignInOption.ANONYMOUS, + DashboardSignInOption.BASIC, + DashboardSignInOption.SAML, + ]); + }); + + it('updates selected sign-in options', async () => { + (updateDashboardSignInOptions as jest.Mock).mockResolvedValue({ message: 'ok' }); + + const component = shallow( + + ); + + const handleUpdate = component.find(SignInOptionsModal).prop('handleUpdate') as ( + selectedOptions: Array<{ name: DashboardSignInOption; displayName: string; status: boolean }> + ) => Promise; + + await handleUpdate([ + { + name: DashboardSignInOption.SAML, + displayName: 'SAML', + status: false, + }, + ]); + + expect(updateDashboardSignInOptions).toHaveBeenCalledWith({}, [DashboardSignInOption.SAML]); + }); +}); + +describe('SignInOptionsModal', () => { + const dashboardOptions = [ + { + name: DashboardSignInOption.BASIC, + displayName: 'Basic authentication', + status: true, + }, + { + name: DashboardSignInOption.SAML, + displayName: 'SAML', + status: false, + }, + ]; + + it('shows a warning and disables update when no options are selected', () => { + const component = shallow( + + ); + + component.find('EuiButton[data-test-subj="editDashboardSigninOptions"]').simulate('click'); + + const selectionConfig = component.find('EuiInMemoryTable').prop('selection') as { + onSelectionChange: (selectedOptions: typeof dashboardOptions) => void; + }; + + selectionConfig.onSelectionChange([]); + component.update(); + + expect(component.find(EuiCallOut).prop('title')).toBe('Select at least one sign-in option.'); + expect( + component.find('EuiButton[data-test-subj="updateDashboardSigninOptions"]').prop('disabled') + ).toBe(true); + }); +}); diff --git a/public/apps/configuration/types.ts b/public/apps/configuration/types.ts index 6736d8e12..4d785c58e 100644 --- a/public/apps/configuration/types.ts +++ b/public/apps/configuration/types.ts @@ -150,3 +150,16 @@ export interface FormRowDeps { helpLink?: string; helpText?: string; } + +export enum DashboardSignInOption { + BASIC = 'basicauth', + OPEN_ID = 'openid', + SAML = 'saml', + ANONYMOUS = 'anonymous', +} + +export interface DashboardOption { + name: DashboardSignInOption; + displayName: string; + status: boolean; +} diff --git a/public/apps/login/login-page.tsx b/public/apps/login/login-page.tsx index ebf5f63e0..645f40909 100644 --- a/public/apps/login/login-page.tsx +++ b/public/apps/login/login-page.tsx @@ -35,6 +35,8 @@ import { OPENID_AUTH_LOGIN_WITH_FRAGMENT, SAML_AUTH_LOGIN_WITH_FRAGMENT, } from '../../../common'; +import { getDashboardsSignInOptions } from '../../utils/dashboards-info-utils'; +import { DashboardSignInOption } from '../configuration/types'; import { getSavedTenant } from '../../utils/storage-utils'; interface LoginPageDeps { @@ -100,6 +102,21 @@ export function LoginPage(props: LoginPageDeps) { const [loginError, setloginError] = useState(''); const [usernameValidationFailed, setUsernameValidationFailed] = useState(false); const [passwordValidationFailed, setPasswordValidationFailed] = useState(false); + const [dynamicSignInOptions, setDynamicSignInOptions] = React.useState< + DashboardSignInOption[] | null + >(null); + + React.useEffect(() => { + const loadDynamicSignInOptions = async () => { + try { + setDynamicSignInOptions(await getDashboardsSignInOptions(props.http)); + } catch (error) { + setDynamicSignInOptions(null); + } + }; + + loadDynamicSignInOptions(); + }, [props.http]); let errorLabel: any = null; if (loginFailed) { @@ -131,6 +148,10 @@ export function LoginPage(props: LoginPageDeps) { } try { + const isValid = await reValidateSignInOption(DashboardSignInOption.BASIC); + if (!isValid) { + return; + } await validateCurrentPassword(props.http, username, password); redirect(props.http.basePath.serverBasePath); } catch (error) { @@ -154,9 +175,14 @@ export function LoginPage(props: LoginPageDeps) { data-test-subj="submit" aria-label={buttonId} size="s" - type="prime" + type="button" className={buttonConfig.buttonstyle || 'btn-login'} - href={loginEndPointWithPath} + onClick={async (event) => { + event.preventDefault(); + if (await reValidateSignInOption(authType as DashboardSignInOption)) { + window.location.assign(loginEndPointWithPath); + } + }} iconType={buttonConfig.showbrandimage ? buttonConfig.brandimage : ''} > {buttonConfig.buttonname} @@ -165,21 +191,62 @@ export function LoginPage(props: LoginPageDeps) { ); }; + const reValidateSignInOption = async (signInOption: DashboardSignInOption) => { + try { + const availableSignInOptions = await getDashboardsSignInOptions(props.http); + + if (!availableSignInOptions.includes(signInOption)) { + window.location.reload(); + return false; + } + } catch (error) { + return true; + } + + return true; + }; + + const mapDynamicSignInOptionsToAuthTypes = (options: DashboardSignInOption[]) => { + return options + .map((option) => { + switch (option) { + case DashboardSignInOption.BASIC: + return AuthType.BASIC; + case DashboardSignInOption.OPEN_ID: + return AuthType.OPEN_ID; + case DashboardSignInOption.SAML: + return AuthType.SAML; + case DashboardSignInOption.ANONYMOUS: + return AuthType.ANONYMOUS; + default: + return undefined; + } + }) + .filter((option): option is AuthType => Boolean(option)); + }; + const formOptions = (options: string | string[]) => { let formBody = []; const formBodyOp = []; - let authOpts: string[] = []; + let authOpts: string[] = + dynamicSignInOptions && dynamicSignInOptions.length > 0 + ? mapDynamicSignInOptionsToAuthTypes(dynamicSignInOptions) + : []; - // Convert auth options to a usable array - if (typeof options === 'string') { - if (options !== '') { - authOpts.push(options.toLowerCase()); - } - } else if (!(options && options.length === 1 && options[0] === '')) { - authOpts = [...options]; - } if (authOpts.length === 0) { - authOpts.push(AuthType.BASIC); + if (typeof options === 'string') { + if (options !== '') { + authOpts.push(options.toLowerCase()); + } + } else if (!(options && options.length === 1 && options[0] === '')) { + authOpts = [...options]; + } + if (authOpts.length === 0) { + authOpts.push(AuthType.BASIC); + } + if (props.config.auth.anonymous_auth_enabled && !authOpts.includes(AuthType.ANONYMOUS)) { + authOpts.push(AuthType.ANONYMOUS); + } } // Remove proxy and jwt from the list because they do not have a login button @@ -232,13 +299,6 @@ export function LoginPage(props: LoginPageDeps) { ); - if (props.config.auth.anonymous_auth_enabled) { - const anonymousConfig = props.config.ui[AuthType.ANONYMOUS].login; - formBody.push( - renderLoginButton(AuthType.ANONYMOUS, ANONYMOUS_AUTH_LOGIN, anonymousConfig) - ); - } - if (authOpts.length > 1) { // Add a separator between the username/password form and the other login options formBody.push(); @@ -261,6 +321,13 @@ export function LoginPage(props: LoginPageDeps) { formBodyOp.push(renderLoginButton(AuthType.SAML, samlAuthLoginUrl, samlConfig)); break; } + case AuthType.ANONYMOUS: { + const anonymousConfig = props.config.ui[AuthType.ANONYMOUS].login; + formBody.push( + renderLoginButton(AuthType.ANONYMOUS, ANONYMOUS_AUTH_LOGIN, anonymousConfig) + ); + break; + } default: { setloginFailed(true); setloginError( diff --git a/public/apps/login/test/__snapshots__/login-page.test.tsx.snap b/public/apps/login/test/__snapshots__/login-page.test.tsx.snap index d36178728..0cedb0951 100644 --- a/public/apps/login/test/__snapshots__/login-page.test.tsx.snap +++ b/public/apps/login/test/__snapshots__/login-page.test.tsx.snap @@ -112,10 +112,10 @@ exports[`Login page renders renders with config value for multiauth 1`] = ` aria-label="openid_login_button" className="test-btn-style" data-test-subj="submit" - href="/app/opensearch-dashboards/auth/openid/captureUrlFragment" iconType="http://localhost:5601/images/test.png" + onClick={[Function]} size="s" - type="prime" + type="button" > Button1 @@ -132,10 +132,10 @@ exports[`Login page renders renders with config value for multiauth 1`] = ` aria-label="saml_login_button" className="test-btn-style" data-test-subj="submit" - href="/app/opensearch-dashboards/auth/saml/captureUrlFragment" iconType="http://localhost:5601/images/test.png" + onClick={[Function]} size="s" - type="prime" + type="button" > Button2 @@ -234,6 +234,16 @@ exports[`Login page renders renders with config value for multiauth with anonymo Log in + + + - - - Button1 @@ -294,10 +294,10 @@ exports[`Login page renders renders with config value for multiauth with anonymo aria-label="saml_login_button" className="test-btn-style" data-test-subj="submit" - href="/app/opensearch-dashboards/auth/saml/captureUrlFragment" iconType="http://localhost:5601/images/test.png" + onClick={[Function]} size="s" - type="prime" + type="button" > Button2 @@ -396,6 +396,16 @@ exports[`Login page renders renders with config value with anonymous auth enable Log in + + + @@ -508,6 +518,16 @@ exports[`Login page renders renders with config value with anonymous auth enable Log in + + + diff --git a/public/apps/login/test/login-page.test.tsx b/public/apps/login/test/login-page.test.tsx index 167c9f148..0408ed773 100644 --- a/public/apps/login/test/login-page.test.tsx +++ b/public/apps/login/test/login-page.test.tsx @@ -13,8 +13,8 @@ * permissions and limitations under the License. */ -import { shallow } from 'enzyme'; -import React from 'react'; +import { mount, shallow } from 'enzyme'; +import React, { act } from 'react'; import { ClientConfigType } from '../../../types'; import { LoginPage, extractNextUrlFromWindowLocation, getNextPath } from '../login-page'; import { validateCurrentPassword } from '../../../utils/login-utils'; @@ -22,11 +22,16 @@ import { API_AUTH_LOGOUT } from '../../../../common'; import { chromeServiceMock } from '../../../../../../src/core/public/mocks'; import { AuthType } from '../../../../common'; import { setSavedTenant } from '../../../utils/storage-utils'; +import { getDashboardsSignInOptions } from '../../../utils/dashboards-info-utils'; jest.mock('../../../utils/login-utils', () => ({ validateCurrentPassword: jest.fn(), })); +jest.mock('../../../utils/dashboards-info-utils', () => ({ + getDashboardsSignInOptions: jest.fn(), +})); + const configUI = { basicauth: { login: { @@ -144,6 +149,7 @@ describe('Login page', () => { beforeEach(() => { chrome = chromeServiceMock.createStartContract(); + (getDashboardsSignInOptions as jest.Mock).mockRejectedValue(new Error('not configured')); }); describe('renders', () => { @@ -263,8 +269,6 @@ describe('Login page', () => { describe('event trigger testing', () => { let component; - const setState = jest.fn(); - const useState = jest.spyOn(React, 'useState'); const config: ClientConfigType = { ui: configUiDefault, auth: { @@ -272,8 +276,7 @@ describe('Login page', () => { }, }; beforeEach(() => { - useState.mockImplementation((initialValue) => [initialValue, setState]); - component = shallow( + component = mount( ); }); @@ -282,23 +285,23 @@ describe('Login page', () => { const event = { target: { value: 'dummy' }, } as React.ChangeEvent; - component.find('[data-test-subj="user-name"]').simulate('change', event); - expect(setState).toBeCalledWith('dummy'); + component.find('input[data-test-subj="user-name"]').simulate('change', event); + component.update(); + expect(component.find('input[data-test-subj="user-name"]').prop('value')).toBe('dummy'); }); it('should update password field on change event', () => { const event = { target: { value: 'dummy' }, } as React.ChangeEvent; - component.find('[data-test-subj="password"]').simulate('change', event); - expect(setState).toBeCalledWith('dummy'); + component.find('input[data-test-subj="password"]').simulate('change', event); + component.update(); + expect(component.find('input[data-test-subj="password"]').prop('value')).toBe('dummy'); }); }); describe('handle submit event', () => { let component; - const useState = jest.spyOn(React, 'useState'); - const setState = jest.fn(); const config: ClientConfigType = { ui: configUiDefault, auth: { @@ -306,25 +309,40 @@ describe('Login page', () => { }, }; beforeEach(() => { - useState.mockImplementation(() => ['user1', setState]); - useState.mockImplementation(() => ['password1', setState]); - component = shallow( + (validateCurrentPassword as jest.Mock).mockResolvedValue(undefined); + component = mount( ); }); - it('submit click event', () => { + it('submit click event', async () => { window = Object.create(window); const url = 'http://dummy.com'; Object.defineProperty(window, 'location', { value: { href: url, + protocol: 'http:', + host: 'dummy.com', + search: '', + hash: '', }, }); - component.find('[data-test-subj="submit"]').simulate('click', { - preventDefault: () => {}, + component.find('input[data-test-subj="user-name"]').simulate('change', { + target: { value: 'user1' }, + }); + component.find('input[data-test-subj="password"]').simulate('change', { + target: { value: 'password1' }, }); + + await act(async () => { + component.find('button[aria-label="basicauth_login_button"]').simulate('click', { + preventDefault: () => {}, + }); + }); + component.update(); + expect(validateCurrentPassword).toHaveBeenCalledTimes(1); + expect(validateCurrentPassword).toHaveBeenCalledWith(mockHttpStart, 'user1', 'password1'); }); }); }); diff --git a/public/types.ts b/public/types.ts index d2895f371..711dcebae 100644 --- a/public/types.ts +++ b/public/types.ts @@ -56,6 +56,7 @@ export interface DashboardsInfo { private_tenant_enabled?: boolean; default_tenant: string; password_validation_error_message: string; + sign_in_options?: string[]; resource_sharing_enabled?: boolean; } diff --git a/public/utils/dashboards-info-utils.tsx b/public/utils/dashboards-info-utils.tsx index 400e4da1b..0dbd624fb 100644 --- a/public/utils/dashboards-info-utils.tsx +++ b/public/utils/dashboards-info-utils.tsx @@ -14,7 +14,8 @@ */ import { HttpStart } from 'opensearch-dashboards/public'; -import { API_ENDPOINT_DASHBOARDSINFO } from '../../common'; +import { API_ENDPOINT_DASHBOARDSINFO, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS } from '../../common'; +import { DashboardSignInOption } from '../apps/configuration/types'; import { DashboardsInfo } from '../types'; import { createLocalClusterRequestContext } from '../apps/configuration/utils/request-utils'; @@ -32,3 +33,23 @@ export async function getDashboardsInfoSafe(http: HttpStart): Promise({ + http, + url: API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS, + }); +} + +export async function updateDashboardSignInOptions( + http: HttpStart, + signInOptions: DashboardSignInOption[] +) { + return await createLocalClusterRequestContext().httpPut({ + http, + url: API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS, + body: { + sign_in_options: signInOptions, + }, + }); +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 270d05f71..2541bf3ef 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -22,7 +22,13 @@ import { RequestHandlerContext, OpenSearchDashboardsRequest, } from 'opensearch-dashboards/server'; -import { API_PREFIX, CONFIGURATION_API_PREFIX, isValidResourceName } from '../../common'; +import { + API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS, + API_PREFIX, + CONFIGURATION_API_PREFIX, + isValidResourceName, +} from '../../common'; +import { DashboardSignInOption } from '../../public/apps/configuration/types'; // TODO: consider to extract entity CRUD operations and put it into a client class export function defineRoutes(router: IRouter, dataSourceEnabled: boolean) { @@ -605,6 +611,94 @@ export function defineRoutes(router: IRouter, dataSourceEnabled: boolean) { } ); + router.get( + { + path: API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS, + validate: false, + options: { + authRequired: false, + }, + }, + async ( + context, + request, + response + ): Promise> => { + try { + const esResp = await context.security_plugin.esClient.callAsInternalUser( + 'opensearch_security.dashboardsinfo' + ); + const normalizedSignInOptions = Array.isArray(esResp.sign_in_options) + ? esResp.sign_in_options + .map((option) => { + switch (String(option).toLowerCase()) { + case DashboardSignInOption.BASIC: + case 'basic': + return DashboardSignInOption.BASIC; + case DashboardSignInOption.OPEN_ID: + return DashboardSignInOption.OPEN_ID; + case DashboardSignInOption.SAML: + return DashboardSignInOption.SAML; + case DashboardSignInOption.ANONYMOUS: + return DashboardSignInOption.ANONYMOUS; + default: + return undefined; + } + }) + .filter((option): option is DashboardSignInOption => Boolean(option)) + : []; + + return response.ok({ + body: normalizedSignInOptions, + }); + } catch (error) { + return errorResponse(response, error); + } + } + ); + + router.put( + { + path: API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS, + validate: { + body: schema.object({ + sign_in_options: schema.arrayOf( + schema.oneOf([ + schema.literal(DashboardSignInOption.BASIC), + schema.literal(DashboardSignInOption.OPEN_ID), + schema.literal(DashboardSignInOption.SAML), + schema.literal(DashboardSignInOption.ANONYMOUS), + ]), + { defaultValue: [DashboardSignInOption.BASIC], minSize: 1 } + ), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + try { + const esResp = await context.security_plugin.esClient + .asScoped(request) + .callAsCurrentUser('opensearch_security.tenancy_configs', { + body: { + sign_in_options: request.body.sign_in_options, + }, + }); + + return response.ok({ + body: { + message: esResp.message, + }, + }); + } catch (error) { + return errorResponse(response, error); + } + } + ); + /** * Gets audit log configuration。 * diff --git a/test/cypress/e2e/oidc/oidc_auth_test.spec.js b/test/cypress/e2e/oidc/oidc_auth_test.spec.js index 514a20a4b..bad349b54 100644 --- a/test/cypress/e2e/oidc/oidc_auth_test.spec.js +++ b/test/cypress/e2e/oidc/oidc_auth_test.spec.js @@ -137,8 +137,7 @@ describe('Log in via OIDC', () => { localStorage.setItem('home:newThemeModal:show', 'false'); - cy.get('#private').should('be.enabled'); - cy.get('#private').click({ force: true }); + cy.get('#private').should('be.visible').and('be.enabled').click(); cy.get('button[data-test-subj="confirm"]').click(); diff --git a/test/cypress/e2e/saml/saml_auth_test.spec.js b/test/cypress/e2e/saml/saml_auth_test.spec.js index 2839cf42e..d8efb43e3 100644 --- a/test/cypress/e2e/saml/saml_auth_test.spec.js +++ b/test/cypress/e2e/saml/saml_auth_test.spec.js @@ -164,8 +164,7 @@ describe('Log in via SAML', () => { }); } - cy.get('#private').should('be.enabled'); - cy.get('#private').click({ force: true }); + cy.get('#private').should('be.visible').and('be.enabled').click(); cy.get('button[data-test-subj="confirm"]').click(); diff --git a/test/jest_integration/security_entity_api.test.ts b/test/jest_integration/security_entity_api.test.ts index ecae09cac..d370d58db 100644 --- a/test/jest_integration/security_entity_api.test.ts +++ b/test/jest_integration/security_entity_api.test.ts @@ -25,6 +25,7 @@ import { ADMIN_PASSWORD, AUTHORIZATION_HEADER_NAME, } from '../constant'; +import { API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS } from '../../common'; import { extractAuthCookie, getAuthCookie } from '../helper/cookie'; import { createOrUpdateEntityAsAdmin, @@ -401,6 +402,34 @@ describe('start OpenSearch Dashboards server', () => { expect(restApiInfoResponse.status).toEqual(200); }); + it('gets dashboard sign-in options without authentication', async () => { + const response = await osdTestServer.request + .get(root, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS) + .unset(AUTHORIZATION_HEADER_NAME); + + expect(response.status).toEqual(200); + expect(Array.isArray(response.body)).toEqual(true); + expect(response.body).toContain('basicauth'); + }); + + it('updates dashboard sign-in options', async () => { + const currentOptionsResponse = await osdTestServer.request + .get(root, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS); + + expect(currentOptionsResponse.status).toEqual(200); + + const response = await osdTestServer.request + .put(root, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS) + .set(AUTHORIZATION_HEADER_NAME, ADMIN_CREDENTIALS) + .send({ + sign_in_options: currentOptionsResponse.body, + }); + + expect(response.status).toEqual(200); + expect(response.body.message).toBeDefined(); + }); + it('index_mappings', async () => { const response = await osdTestServer.request .post(root, '/api/v1/configuration/index_mappings')