Skip to content

Commit

Permalink
Use value from nextUrl when logging in with SAML and multiauth enabled (
Browse files Browse the repository at this point in the history
#1557)

* Fix bug with nextUrl using SAML and multiauth enabled

Signed-off-by: Craig Perkins <[email protected]>
  • Loading branch information
cwperks authored Aug 24, 2023
1 parent c10031f commit f655ccf
Show file tree
Hide file tree
Showing 6 changed files with 330 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,5 @@ jobs:
run: |
echo "check if opensearch is ready"
curl -XGET https://localhost:9200 -u 'admin:admin' -k
node .\test\run_jest_tests.js --config .\test\jest.config.server.js --testPathIgnorePatterns saml_auth.test.ts
node .\test\run_jest_tests.js --config .\test\jest.config.server.js --testPathIgnorePatterns saml_auth.test.ts --testPathIgnorePatterns saml_multiauth.test.ts
working-directory: ${{ steps.install-dashboards.outputs.plugin-directory }}
2 changes: 2 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ Clone [OpenSearch Dashboards Functional Test]( https://github.com/opensearch-pro
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.
The integration tests take advantage of [npm "pre" scripts](https://docs.npmjs.com/cli/v9/using-npm/scripts) to run a node based SAML IdP for integration tests related to SAML authentication. This will run a background process that listens on port 7000.
## Submitting Changes
See [CONTRIBUTING](CONTRIBUTING.md).
Expand Down
2 changes: 1 addition & 1 deletion common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const API_AUTH_LOGOUT = '/auth/logout';
export const OPENID_AUTH_LOGIN = '/auth/openid/login';
export const SAML_AUTH_LOGIN = '/auth/saml/login';
export const ANONYMOUS_AUTH_LOGIN = '/auth/anonymous';
export const SAML_AUTH_LOGIN_WITH_FRAGMENT = '/auth/saml/captureUrlFragment?nextUrl=%2F';
export const SAML_AUTH_LOGIN_WITH_FRAGMENT = '/auth/saml/captureUrlFragment';

export const OPENID_AUTH_LOGOUT = '/auth/openid/logout';
export const SAML_AUTH_LOGOUT = '/auth/saml/logout';
Expand Down
19 changes: 16 additions & 3 deletions public/apps/login/login-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ function redirect(serverBasePath: string) {
window.location.href = nextUrl + window.location.hash;
}

export function extractNextUrlFromWindowLocation(): string {
const urlParams = new URLSearchParams(window.location.search);
let nextUrl = urlParams.get('nextUrl');
if (!nextUrl || nextUrl.toLowerCase().includes('//')) {
nextUrl = encodeURIComponent('/');
} else {
nextUrl = encodeURIComponent(nextUrl);
const hash = window.location.hash || '';
nextUrl += hash;
}
return `?nextUrl=${nextUrl}`;
}

export function LoginPage(props: LoginPageDeps) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
Expand Down Expand Up @@ -220,9 +233,9 @@ export function LoginPage(props: LoginPageDeps) {
}
case AuthType.SAML: {
const samlConfig = props.config.ui[AuthType.SAML].login;
formBodyOp.push(
renderLoginButton(AuthType.SAML, SAML_AUTH_LOGIN_WITH_FRAGMENT, samlConfig)
);
const nextUrl = extractNextUrlFromWindowLocation();
const samlAuthLoginUrl = SAML_AUTH_LOGIN_WITH_FRAGMENT + nextUrl;
formBodyOp.push(renderLoginButton(AuthType.SAML, samlAuthLoginUrl, samlConfig));
break;
}
default: {
Expand Down
23 changes: 22 additions & 1 deletion public/apps/login/test/login-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { shallow } from 'enzyme';
import React from 'react';
import { ClientConfigType } from '../../../types';
import { LoginPage } from '../login-page';
import { LoginPage, extractNextUrlFromWindowLocation } from '../login-page';
import { validateCurrentPassword } from '../../../utils/login-utils';
import { API_AUTH_LOGOUT } from '../../../../common';

Expand Down Expand Up @@ -62,6 +62,27 @@ const configUiDefault = {
},
};

describe('test extractNextUrlFromWindowLocation', () => {
test('extract next url from window with nextUrl', () => {
// Trick to mock window.location
const originalLocation = window.location;
delete window.location;
window.location = new URL(
"http://localhost:5601/app/login?nextUrl=%2Fapp%2Fdashboards#/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)"
) as any;
expect(extractNextUrlFromWindowLocation()).toEqual(
"?nextUrl=%2Fapp%2Fdashboards#/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)"
);
});

test('extract next url from window without nextUrl', () => {
const originalLocation = window.location;
delete window.location;
window.location = new URL('http://localhost:5601/app/home');
expect(extractNextUrlFromWindowLocation()).toEqual('?nextUrl=%2F');
});
});

describe('Login page', () => {
const mockHttpStart = {
basePath: {
Expand Down
288 changes: 288 additions & 0 deletions test/jest_integration/saml_multiauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
/*
* 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 signInBtnXPath = '//*[@id="btn-sign-in"]';
const samlLogInButton = '//a[@aria-label="saml_login_button"]';
// 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: ['basicauth', 'saml'],
multiple_auth_enabled: true,
},
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: ['[email protected]'],
},
],
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: ['[email protected]'],
},
],
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 Dashboards and resume from nextUrl', 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 loginUrlWithNextUrl = `http://localhost:5601/app/login?nextUrl=%2Fapp%2Fdashboards#/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(loginUrlWithNextUrl);
await driver.wait(until.elementsLocated(By.xpath(samlLogInButton)), 20000);
await driver.findElement(By.xpath(samlLogInButton)).click();
await driver.wait(until.elementsLocated(By.xpath(signInBtnXPath)), 20000);
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();
console.log('windowHash: ' + windowHash);
expect(windowHash).toEqual(urlWithHash);
const cookie = await driver.manage().getCookies();
expect(cookie.length).toEqual(3);
await driver.manage().deleteAllCookies();
await driver.quit();
});

it('Login to Dashboards without nextUrl', async () => {
const urlWithoutHash = `http://localhost:5601/app/home`;
const loginUrl = `http://localhost:5601/app/login`;
const driver = getDriver(browser, options).build();
await driver.manage().deleteAllCookies();
await driver.get(loginUrl);
await driver.wait(until.elementsLocated(By.xpath(samlLogInButton)), 20000);
await driver.findElement(By.xpath(samlLogInButton)).click();
await driver.wait(until.elementsLocated(By.xpath(signInBtnXPath)), 20000);
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();
console.log('windowHash: ' + windowHash);
expect(windowHash).toEqual(urlWithoutHash);
const cookie = await driver.manage().getCookies();
expect(cookie.length).toEqual(3);
await driver.manage().deleteAllCookies();
await driver.quit();
});
});

function getDriver(browser: string, options: Options) {
return new Builder().forBrowser(browser).setFirefoxOptions(options);
}

0 comments on commit f655ccf

Please sign in to comment.