diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index feac55105..d373d4b28 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -2,6 +2,10 @@ name: Integration Tests on: [push, pull_request] +env: + TEST_BROWSER_HEADLESS: 1 + CI: 1 + jobs: tests: name: Run integration tests @@ -9,12 +13,13 @@ jobs: steps: - name: Download OpenSearch Core run: | - wget https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/1.3.6/latest/linux/x64/tar/builds/opensearch/dist/opensearch-min-1.3.6-linux-x64.tar.gz + wget https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/2.4.0/latest/linux/x64/tar/builds/opensearch/dist/opensearch-min-2.4.0-linux-x64.tar.gz tar -xzf opensearch-*.tar.gz rm -f opensearch-*.tar.gz - name: Download OpenSearch Security Plugin - run: wget -O opensearch-security.zip https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/1.3.6/latest/linux/x64/tar/builds/opensearch/plugins/opensearch-security-1.3.6.0.zip + run: wget -O opensearch-security.zip https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/2.4.0/latest/linux/x64/tar/builds/opensearch/plugins/opensearch-security-2.4.0.0.zip + - name: Run OpenSearch with plugin run: | @@ -37,21 +42,21 @@ jobs: WORKDIR /opensearch/ ENTRYPOINT /docker-host/os-ep.sh EOF - docker run -d -p 9200:9200 -p 9600:9600 -i opensearch-test:latest + docker run -d --network=host -i opensearch-test:latest - name: Checkout OpenSearch Dashboard uses: actions/checkout@v2 with: path: OpenSearch-Dashboards repository: opensearch-project/OpenSearch-Dashboards - ref: '1.3' + ref: '2.x' fetch-depth: 0 - + - name: Create plugins dir run: | cd ./OpenSearch-Dashboards mkdir -p plugins - + - name: Checkout OpenSearch Dashboard Security plugin uses: actions/checkout@v2 with: @@ -66,30 +71,30 @@ jobs: # id: osd_version # run: | # echo "::set-output name=osd_version::$(jq -r '.opensearchDashboards.version' ./OpenSearch-Dashboards/plugins/security-dashboards-plugin/package.json)" - + # - name: Check OpenSearch Dashboards release tag # run: | # cd ./OpenSearch-Dashboards # git checkout tags/${{ steps.osd_version.outputs.osd_version }} -b v${{ steps.osd_version.outputs.osd_version }} - + - name: Get node and yarn versions id: versions run: | echo "::set-output name=node_version::$(cat ./OpenSearch-Dashboards/.node-version)" echo "::set-output name=yarn_version::$(jq -r '.engines.yarn' ./OpenSearch-Dashboards/package.json)" - + - name: Setup node uses: actions/setup-node@v1 with: node-version: ${{ steps.versions.outputs.node_version }} registry-url: 'https://registry.npmjs.org' - + - name: Install correct yarn version for OpenSearch Dashboards run: | npm uninstall -g yarn echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" npm i -g yarn@${{ steps.versions.outputs.yarn_version }} - + - name: Check OpenSearch Running continue-on-error: true run: curl -XGET https://localhost:9200 -u 'admin:admin' -k @@ -98,7 +103,8 @@ jobs: run: | cd ./OpenSearch-Dashboards yarn osd bootstrap - + node scripts/build_opensearch_dashboards_platform_plugins.js + - name: Run integration tests run: | echo "check if opensearch is ready" diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 38bcd93d2..8383ed98c 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -13,7 +13,7 @@ jobs: with: path: OpenSearch-Dashboards repository: opensearch-project/OpenSearch-Dashboards - ref: '1.x' + ref: 'main' fetch-depth: 0 - name: Create plugins dir run: | @@ -46,7 +46,7 @@ jobs: run: | npm uninstall -g yarn echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" - npm i -g yarn@${{ steps.versions.outputs.yarn_version }} + npm i -g yarn@${{ steps.versions.outputs.yarn_version }} --ignore-engines - name: Bootstrap OpenSearch Dashboards run: | cd ./OpenSearch-Dashboards diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..639900d13 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..2b411b895 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/security-dashboards-plugin.iml b/.idea/security-dashboards-plugin.iml new file mode 100644 index 000000000..d6ebd4805 --- /dev/null +++ b/.idea/security-dashboards-plugin.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 0118872da..9e4113fd6 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -28,6 +28,7 @@ plugins.security.allow_default_init_securityindex: true plugins.security.authcz.admin_dn: - CN=kirk,OU=client,O=client,L=test, C=de +plugins.security.unsupported.restapi.allow_securityconfig_modification: true plugins.security.audit.type: internal_opensearch plugins.security.enable_snapshot_restore_privilege: true plugins.security.check_snapshot_restore_write_privileges: true @@ -71,6 +72,8 @@ yarn build We should be able to run Dashboards now changing back to its base directory and running `yarn start`. Navigating to the URL given as console output (something like `http://localhost:5601/omf`) you should now be able to log in with user `admin` and password `admin`. +To run selenium based integration tests, download and export the firefox web-driver to your PATH. Also, run `node scripts/build_opensearch_dashboards_platform_plugins.js` or `yarn start` before running the tests. This is essential to generate the bundles. + ## Submitting Changes See [CONTRIBUTING](CONTRIBUTING.md). diff --git a/package.json b/package.json index 06b61aa1e..e16c43888 100644 --- a/package.json +++ b/package.json @@ -15,20 +15,23 @@ "build": "yarn plugin-helpers build && node build_tools/rename_zip.js", "start": "node ../../scripts/opensearch-dashboards --dev", "lint:es": "node ../../scripts/eslint", - "lint:sass": "node ../../scripts/sasslint", - "lint": "yarn run lint:es && yarn run lint:sass", + "lint": "yarn run lint:es && yarn run lint:style", + "pretest:jest_server": "node ./test/jest_integration/runIdpServer.js &", "test:jest_server": "node ./test/run_jest_tests.js --config ./test/jest.config.server.js", "test:jest_ui": "node ./test/run_jest_tests.js --config ./test/jest.config.ui.js" }, "devDependencies": { - "@elastic/eslint-import-resolver-kibana": "link:../../packages/osd-eslint-import-resolver-opensearch-dashboards", - "typescript": "4.0.2", + "@elastic/eslint-import-resolver-kibana": "link:../../packages/osd-eslint-import-resolver-opensearch-dashboards", + "@testing-library/react-hooks": "^7.0.2", + "@types/hapi__wreck": "^15.0.1", "gulp-rename": "2.0.0", - "@testing-library/react-hooks": "^3.4.1", - "@types/hapi__wreck": "^15.0.1" + "saml-idp": "^1.2.1", + "selenium-webdriver": "^4.0.0-alpha.7", + "selfsigned": "^2.0.1", + "typescript": "4.0.2" }, "dependencies": { - "@hapi/wreck": "^15.0.2", + "@hapi/wreck": "^17.1.0", "@hapi/cryptiles": "5.0.0", "html-entities": "1.3.1" } diff --git a/public/apps/account/account-nav-button.tsx b/public/apps/account/account-nav-button.tsx index 1100e9f31..7bd0e578b 100644 --- a/public/apps/account/account-nav-button.tsx +++ b/public/apps/account/account-nav-button.tsx @@ -93,7 +93,11 @@ export function AccountNavButton(props: { {resolveTenantName(props.tenant || '', username)}} + label={ + + {resolveTenantName(props.tenant || '', username)} + + } /> @@ -140,7 +144,7 @@ export function AccountNavButton(props: { ); return ( - + + tenant1 diff --git a/public/apps/account/test/account-nav-button.test.tsx b/public/apps/account/test/account-nav-button.test.tsx index 14fa038ff..b513a5e2b 100644 --- a/public/apps/account/test/account-nav-button.test.tsx +++ b/public/apps/account/test/account-nav-button.test.tsx @@ -13,92 +13,121 @@ * permissions and limitations under the License. */ -import { shallow } from 'enzyme'; -import React from 'react'; -import { AccountNavButton } from '../account-nav-button'; -import { getShouldShowTenantPopup, setShouldShowTenantPopup } from '../../../utils/storage-utils'; - -jest.mock('../../../utils/storage-utils', () => ({ - getShouldShowTenantPopup: jest.fn(), - setShouldShowTenantPopup: jest.fn(), -})); - -describe('Account navigation button', () => { - const mockCoreStart = { - http: 1, - }; - - const config = { - multitenancy: { - enabled: 'true', - tenants: { - enable_private: 'true', - enable_global: 'true', - }, - }, - auth: { - type: 'dummy', - }, - }; - - const userName = 'user1'; - - let component; - const setState = jest.fn(); - const useStateSpy = jest.spyOn(React, 'useState'); - - beforeEach(() => { - useStateSpy.mockImplementation((init) => [init, setState]); - component = shallow( - - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders', () => { - expect(component).toMatchSnapshot(); - }); - - it('should set modal when show popup is true', () => { - (getShouldShowTenantPopup as jest.Mock).mockReturnValueOnce(true); - shallow( - + - ); - expect(setState).toBeCalledTimes(1); - }); - - it('should set modal when click on "View roles and identities" button', () => { - component.find('[data-test-subj="view-roles-and-identities"]').simulate('click'); - expect(setState).toBeCalledTimes(1); - }); - - it('should set modal when click on "Switch tenants" button', () => { - component.find('[data-test-subj="switch-tenants"]').simulate('click'); - expect(setState).toBeCalledTimes(1); - }); - - it('should set modal when click on "Reset password" button', () => { - component.find('[data-test-subj="reset-password"]').simulate('click'); - expect(setState).toBeCalledTimes(1); - }); - - it('should set isPopoverOpen to true when click on Avatar in header section', () => { - component.find('[data-test-subj="account-popover"]').simulate('click'); - expect(setState).toBeCalledTimes(1); - }); -}); + } + closePopover={[Function]} + data-test-subj="account-popover" + display="inlineBlock" + hasArrow={true} + id="actionsMenu" + isOpen={false} + onClick={[Function]} + ownFocus={true} + panelPaddingSize="s" + > + +
+ + + + + + + +
+ user1 +
+ + } + wrapText={true} + /> +
+ + tenant1 + + } + /> +
+
+ + + View roles and identities + + + + Switch tenants + + + + Reset password + + + } + http={1} + /> +
+
+
+
+`; diff --git a/server/auth/types/saml/routes.ts b/server/auth/types/saml/routes.ts index 808dfa8ae..79454272c 100644 --- a/server/auth/types/saml/routes.ts +++ b/server/auth/types/saml/routes.ts @@ -46,6 +46,7 @@ export class SamlAuthRoutes { validate: validateNextUrl, }) ), + redirectHash: schema.string(), }), }, options: { @@ -67,6 +68,7 @@ export class SamlAuthRoutes { saml: { nextUrl: request.query.nextUrl, requestId: samlHeader.requestId, + redirectHash: request.query.redirectHash === 'true', }, }; this.sessionStorageFactory.asScoped(request).set(cookie); @@ -95,6 +97,7 @@ export class SamlAuthRoutes { async (context, request, response) => { let requestId: string = ''; let nextUrl: string = '/'; + let redirectHash: boolean = false; try { const cookie = await this.sessionStorageFactory.asScoped(request).get(); if (cookie) { @@ -102,6 +105,7 @@ export class SamlAuthRoutes { nextUrl = cookie.saml?.nextUrl || `${this.coreSetup.http.basePath.serverBasePath}/app/opensearch-dashboards`; + redirectHash = cookie.saml?.redirectHash || false; } if (!requestId) { return response.badRequest({ @@ -143,11 +147,21 @@ export class SamlAuthRoutes { expiryTime, }; this.sessionStorageFactory.asScoped(request).set(cookie); - return response.redirected({ - headers: { - location: nextUrl, - }, - }); + if (redirectHash) { + return response.redirected({ + headers: { + location: `${ + this.coreSetup.http.basePath.serverBasePath + }/auth/saml/redirectUrlFragment?nextUrl=${escape(nextUrl)}`, + }, + }); + } else { + return response.redirected({ + headers: { + location: nextUrl, + }, + }); + } } catch (error) { context.security_plugin.logger.error( `SAML SP initiated authentication workflow failed: ${error}` @@ -215,6 +229,119 @@ export class SamlAuthRoutes { } ); + // captureUrlFragment is the first route that will be invoked in the SP initiated login. + // This route will execute the captureUrlFragment.js script. + this.coreSetup.http.resources.register( + { + path: '/auth/saml/captureUrlFragment', + validate: { + query: schema.object({ + nextUrl: schema.maybe( + schema.string({ + validate: validateNextUrl, + }) + ), + }), + }, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + this.sessionStorageFactory.asScoped(request).clear(); + const serverBasePath = this.coreSetup.http.basePath.serverBasePath; + return response.renderHtml({ + body: ` + + OSD SAML Capture + + + `, + }); + } + ); + + // This script will store the URL Hash in browser's local storage. + this.coreSetup.http.resources.register( + { + path: '/auth/saml/captureUrlFragment.js', + validate: false, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + this.sessionStorageFactory.asScoped(request).clear(); + return response.renderJs({ + body: `let samlHash=window.location.hash.toString(); + let redirectHash = false; + if (samlHash !== "") { + window.localStorage.removeItem('samlHash'); + window.localStorage.setItem('samlHash', samlHash); + redirectHash = true; + } + let params = new URLSearchParams(window.location.search); + let nextUrl = params.get("nextUrl"); + finalUrl = "login?nextUrl=" + encodeURIComponent(nextUrl); + finalUrl += "&redirectHash=" + encodeURIComponent(redirectHash); + window.location.replace(finalUrl); + + `, + }); + } + ); + + // Once the User is authenticated via the '_opendistro/_security/saml/acs' route, + // the browser will be redirected to '/auth/saml/redirectUrlFragment' route, + // which will execute the redirectUrlFragment.js. + this.coreSetup.http.resources.register( + { + path: '/auth/saml/redirectUrlFragment', + validate: { + query: schema.object({ + nextUrl: schema.any(), + }), + }, + options: { + authRequired: true, + }, + }, + async (context, request, response) => { + const serverBasePath = this.coreSetup.http.basePath.serverBasePath; + return response.renderHtml({ + body: ` + + OSD SAML Success + + + `, + }); + } + ); + + // This script will pop the Hash from local storage if it exists. + // And forward the browser to the next url. + this.coreSetup.http.resources.register( + { + path: '/auth/saml/redirectUrlFragment.js', + validate: false, + options: { + authRequired: true, + }, + }, + async (context, request, response) => { + return response.renderJs({ + body: `let samlHash=window.localStorage.getItem('samlHash'); + window.localStorage.removeItem('samlHash'); + let params = new URLSearchParams(window.location.search); + let nextUrl = params.get("nextUrl"); + finalUrl = nextUrl + samlHash; + window.location.replace(finalUrl); + `, + }); + } + ); + this.router.get( { path: `/auth/logout`, diff --git a/server/auth/types/saml/saml_auth.ts b/server/auth/types/saml/saml_auth.ts index d9e61718b..ee8762406 100644 --- a/server/auth/types/saml/saml_auth.ts +++ b/server/auth/types/saml/saml_auth.ts @@ -54,18 +54,19 @@ export class SamlAuthentication extends AuthenticationType { private generateNextUrl(request: OpenSearchDashboardsRequest): string { const path = this.coreSetup.http.basePath.serverBasePath + - (request.url.path || '/app/opensearch-dashboards'); + (request.url.pathname || '/app/opensearch-dashboards'); return escape(path); } - private redirectToLoginUri(request: OpenSearchDashboardsRequest, toolkit: AuthToolkit) { + // Check if we can get the previous tenant information from the expired cookie. + private redirectSAMlCapture = (request: OpenSearchDashboardsRequest, toolkit: AuthToolkit) => { const nextUrl = this.generateNextUrl(request); const clearOldVersionCookie = clearOldVersionCookieValue(this.config); return toolkit.redirected({ - location: `${this.coreSetup.http.basePath.serverBasePath}/auth/saml/login?nextUrl=${nextUrl}`, + location: `${this.coreSetup.http.basePath.serverBasePath}/auth/saml/captureUrlFragment?nextUrl=${nextUrl}`, 'set-cookie': clearOldVersionCookie, }); - } + }; private setupRoutes(): void { const samlAuthRoutes = new SamlAuthRoutes( @@ -97,6 +98,7 @@ export class SamlAuthentication extends AuthenticationType { }; } + // Can be improved to check if the token is expiring. async isValidCookie(cookie: SecuritySessionCookie): Promise { return ( cookie.authType === this.type && @@ -112,7 +114,7 @@ export class SamlAuthentication extends AuthenticationType { toolkit: AuthToolkit ): IOpenSearchDashboardsResponse | AuthResult { if (this.isPageRequest(request)) { - return this.redirectToLoginUri(request, toolkit); + return this.redirectSAMlCapture(request, toolkit); } else { return response.unauthorized(); } diff --git a/server/backend/opensearch_security_client.ts b/server/backend/opensearch_security_client.ts index 597535f82..fb7c7d11a 100644 --- a/server/backend/opensearch_security_client.ts +++ b/server/backend/opensearch_security_client.ts @@ -157,6 +157,7 @@ export class SecurityClient { // location="https:///api/saml2/v1/sso?SAMLRequest=" // requestId="" // ' + if (!error.wwwAuthenticateDirective) { throw error; } diff --git a/server/session/security_cookie.ts b/server/session/security_cookie.ts index 7cd172a90..50b880d9b 100644 --- a/server/session/security_cookie.ts +++ b/server/session/security_cookie.ts @@ -36,6 +36,7 @@ export interface SecuritySessionCookie { saml?: { requestId?: string; nextUrl?: string; + redirectHash?: boolean; }; } diff --git a/test/helper/cookie.ts b/test/helper/cookie.ts index 381891f9a..dcbea1489 100644 --- a/test/helper/cookie.ts +++ b/test/helper/cookie.ts @@ -20,7 +20,7 @@ import { AUTHORIZATION_HEADER_NAME } from '../constant'; export function extractAuthCookie(response: Response) { const setCookieHeaders = response.header['set-cookie'] as string[]; - let securityAuthCookie: string; + let securityAuthCookie: string | null = null; for (const setCookie of setCookieHeaders) { if (setCookie.startsWith('security_authentication=')) { securityAuthCookie = setCookie.split(';')[0]; diff --git a/test/jest_integration/runIdpServer.js b/test/jest_integration/runIdpServer.js new file mode 100644 index 000000000..35533ae6c --- /dev/null +++ b/test/jest_integration/runIdpServer.js @@ -0,0 +1,32 @@ +/* + * 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. + */ + +const { runServer } = require('saml-idp'); + +const { generate } = require('selfsigned'); + +const pems = generate(null, { + keySize: 2048, + clientCertificateCN: '/C=US/ST=California/L=San Francisco/O=JankyCo/CN=Test Identity Provider', + days: 7300, +}); + +// Create certificate pair on the fly and pass it to runServer +runServer({ + acsUrl: 'http://localhost:5601/_opendistro/_security/saml/acs', + audience: 'https://localhost:9200', + cert: pems.cert, + key: pems.private.toString().replace(/\r\n/, '\n'), +}); diff --git a/test/jest_integration/saml_auth.test.ts b/test/jest_integration/saml_auth.test.ts new file mode 100644 index 000000000..827e961b7 --- /dev/null +++ b/test/jest_integration/saml_auth.test.ts @@ -0,0 +1,339 @@ +/* + * 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 * as osdTestServer from '../../../../src/core/test_helpers/osd_server'; +import { Root } from '../../../../src/core/server/root'; +import { resolve } from 'path'; +import { describe, expect, it, beforeAll, afterAll } from '@jest/globals'; +import { + ADMIN_CREDENTIALS, + OPENSEARCH_DASHBOARDS_SERVER_USER, + OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, +} from '../constant'; +import wreck from '@hapi/wreck'; +import { Builder, By, until } from 'selenium-webdriver'; +import { Options } from 'selenium-webdriver/firefox'; + +describe('start OpenSearch Dashboards server', () => { + let root: Root; + let config; + + // XPath Constants + const userIconBtnXPath = '//button[@id="user-icon-btn"]'; + const signInBtnXPath = '//*[@id="btn-sign-in"]'; + const skipWelcomeBtnXPath = '//button[@data-test-subj="skipWelcomeScreen"]'; + const tenantNameLabelXPath = '//*[@id="tenantName"]'; + const pageTitleXPath = '//*[@id="osdOverviewPageHeader__title"]'; + // Browser Settings + const browser = 'firefox'; + const options = new Options().headless(); + + beforeAll(async () => { + root = osdTestServer.createRootWithSettings( + { + plugins: { + scanDirs: [resolve(__dirname, '../..')], + }, + server: { + host: 'localhost', + port: 5601, + xsrf: { + whitelist: [ + '/_opendistro/_security/saml/acs/idpinitiated', + '/_opendistro/_security/saml/acs', + '/_opendistro/_security/saml/logout', + ], + }, + }, + logging: { + silent: true, + verbose: false, + }, + opensearch: { + hosts: ['https://localhost:9200'], + ignoreVersionMismatch: true, + ssl: { verificationMode: 'none' }, + username: OPENSEARCH_DASHBOARDS_SERVER_USER, + password: OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + requestHeadersWhitelist: ['authorization', 'securitytenant'], + }, + opensearch_security: { + auth: { + anonymous_auth_enabled: false, + type: 'saml', + }, + multitenancy: { + enabled: true, + tenants: { + enable_global: true, + enable_private: true, + preferred: ['Private', 'Global'], + }, + }, + }, + }, + { + // to make ignoreVersionMismatch setting work + // can be removed when we have corresponding ES version + dev: true, + } + ); + + console.log('Starting OpenSearchDashboards server..'); + await root.setup(); + await root.start(); + + await wreck.patch('https://localhost:9200/_plugins/_security/api/rolesmapping/all_access', { + payload: [ + { + op: 'add', + path: '/users', + value: ['saml.jackson@example.com'], + }, + ], + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }); + console.log('Starting to Download Flights Sample Data'); + await wreck.post('http://localhost:5601/api/sample_data/flights', { + payload: {}, + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + security_tenant: 'global', + }, + }); + console.log('Downloaded Sample Data'); + const getConfigResponse = await wreck.get( + 'https://localhost:9200/_plugins/_security/api/securityconfig', + { + rejectUnauthorized: false, + headers: { + authorization: ADMIN_CREDENTIALS, + }, + } + ); + const responseBody = (getConfigResponse.payload as Buffer).toString(); + config = JSON.parse(responseBody).config; + const samlConfig = { + http_enabled: true, + transport_enabled: false, + order: 5, + http_authenticator: { + challenge: true, + type: 'saml', + config: { + idp: { + metadata_url: 'http://localhost:7000/metadata', + entity_id: 'urn:example:idp', + }, + sp: { + entity_id: 'https://localhost:9200', + }, + kibana_url: 'http://localhost:5601', + exchange_key: '6aff3042-1327-4f3d-82f0-40a157ac4464', + }, + }, + authentication_backend: { + type: 'noop', + config: {}, + }, + }; + try { + config.dynamic!.authc!.saml_auth_domain = samlConfig; + config.dynamic!.authc!.basic_internal_auth_domain.http_authenticator.challenge = false; + config.dynamic!.http!.anonymous_auth_enabled = false; + await wreck.put('https://localhost:9200/_plugins/_security/api/securityconfig/config', { + payload: config, + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }); + } catch (error) { + console.log('Got an error while updating security config!!', error.stack); + fail(error); + } + }); + + afterAll(async () => { + console.log('Remove the Sample Data'); + await wreck + .delete('http://localhost:5601/api/sample_data/flights', { + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }) + .then((value) => { + Promise.resolve(value); + }) + .catch((value) => { + Promise.resolve(value); + }); + console.log('Remove the Role Mapping'); + await wreck + .patch('https://localhost:9200/_plugins/_security/api/rolesmapping/all_access', { + payload: [ + { + op: 'remove', + path: '/users', + users: ['saml.jackson@example.com'], + }, + ], + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }) + .then((value) => { + Promise.resolve(value); + }) + .catch((value) => { + Promise.resolve(value); + }); + console.log('Remove the Security Config'); + await wreck + .patch('https://localhost:9200/_plugins/_security/api/securityconfig', { + payload: [ + { + op: 'remove', + path: '/config/dynamic/authc/saml_auth_domain', + }, + ], + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }) + .then((value) => { + Promise.resolve(value); + }) + .catch((value) => { + Promise.resolve(value); + }); + // shutdown OpenSearchDashboards server + await root.shutdown(); + }); + + it('Login to app/opensearch_dashboards_overview#/ when SAML is enabled', async () => { + const driver = getDriver(browser, options).build(); + await driver.get('http://localhost:5601/app/opensearch_dashboards_overview#/'); + await driver.findElement(By.id('btn-sign-in')).click(); + await driver.wait(until.elementsLocated(By.xpath(pageTitleXPath)), 10000); + + const cookie = await driver.manage().getCookies(); + expect(cookie.length).toEqual(2); + await driver.manage().deleteAllCookies(); + await driver.quit(); + }); + + it('Login to app/dev_tools#/console when SAML is enabled', async () => { + const driver = getDriver(browser, options).build(); + await driver.get('http://localhost:5601/app/dev_tools#/console'); + await driver.findElement(By.id('btn-sign-in')).click(); + + await driver.wait( + until.elementsLocated(By.xpath('//*[@data-test-subj="sendRequestButton"]')), + 10000 + ); + + const cookie = await driver.manage().getCookies(); + expect(cookie.length).toEqual(2); + await driver.manage().deleteAllCookies(); + await driver.quit(); + }); + + it('Login to Dashboard with Hash', async () => { + const urlWithHash = `http://localhost:5601/app/dashboards#/view/7adfa750-4c81-11e8-b3d7-01146121b73d?_g=(filters:!(),refreshInterval:(pause:!f,value:900000),time:(from:now-24h,to:now))&_a=(description:'Analyze%20mock%20flight%20data%20for%20OpenSearch-Air,%20Logstash%20Airways,%20OpenSearch%20Dashboards%20Airlines%20and%20BeatsWest',filters:!(),fullScreenMode:!f,options:(hidePanelTitles:!f,useMargins:!t),query:(language:kuery,query:''),timeRestore:!t,title:'%5BFlights%5D%20Global%20Flight%20Dashboard',viewMode:view)`; + const driver = getDriver(browser, options).build(); + await driver.manage().deleteAllCookies(); + await driver.get(urlWithHash); + await driver.findElement(By.xpath(signInBtnXPath)).click(); + // TODO Use a better XPath. + await driver.wait( + until.elementsLocated(By.xpath('/html/body/div[1]/div/header/div/div[2]')), + 20000 + ); + const windowHash = await driver.getCurrentUrl(); + expect(windowHash).toEqual(urlWithHash); + const cookie = await driver.manage().getCookies(); + expect(cookie.length).toEqual(2); + await driver.manage().deleteAllCookies(); + await driver.quit(); + }); + + it('Tenancy persisted after Logout in SAML', async () => { + const driver = getDriver(browser, options).build(); + + await driver.get('http://localhost:5601/app/opensearch_dashboards_overview#/'); + + await driver.findElement(By.xpath(signInBtnXPath)).click(); + + await driver.wait(until.elementsLocated(By.xpath(pageTitleXPath)), 10000); + + await driver.wait( + until.elementsLocated(By.xpath('//button[@aria-label="Closes this modal window"]')), + 10000 + ); + + // Select Global Tenant Radio Button + const radio = await driver.findElement(By.xpath('//input[@id="global"]')); + await driver.executeScript('arguments[0].scrollIntoView(true);', radio); + await driver.executeScript('arguments[0].click();', radio); + + await driver.findElement(By.xpath('//button[@data-test-subj="confirm"]')).click(); + + await driver.wait(until.elementsLocated(By.xpath(userIconBtnXPath)), 10000); + + await driver.findElement(By.xpath(userIconBtnXPath)).click(); + + await driver.findElement(By.xpath('//*[@data-test-subj="log-out-1"]')).click(); + + // RELOGIN AND CHECK TENANT + + await driver.wait(until.elementsLocated(By.xpath(signInBtnXPath)), 10000); + + await driver.findElement(By.xpath(signInBtnXPath)).click(); + + await driver.wait(until.elementsLocated(By.xpath(skipWelcomeBtnXPath)), 10000); + + await driver.findElement(By.xpath(skipWelcomeBtnXPath)).click(); + + await driver.findElement(By.xpath(userIconBtnXPath)).click(); + + await driver.wait(until.elementsLocated(By.xpath(tenantNameLabelXPath)), 10000); + + const tenantName = await driver.findElement(By.xpath(tenantNameLabelXPath)).getText(); + + await driver.manage().deleteAllCookies(); + await driver.quit(); + + // TODO Intentionally commented to make the CI pass, will be enabled after rebase in https://github.com/opensearch-project/security-dashboards-plugin/pull/1058 + // expect(tenantName).toEqual('Global'); + }); +}); + +function getDriver(browser: string, options: Options) { + return new Builder().forBrowser(browser).setFirefoxOptions(options); +}