Skip to content

Commit

Permalink
Support deep links inside of RelayState during IdP initiated login.
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed Jun 17, 2020
1 parent 3ee0bf2 commit 85f2076
Show file tree
Hide file tree
Showing 11 changed files with 458 additions and 33 deletions.
52 changes: 52 additions & 0 deletions x-pack/plugins/security/common/is_intern_url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { isInternalURL } from './is_internal_url';

describe('isInternalURL', () => {
function commonTestCases(basePath?: string) {
it('should return `true `if URL includes hash fragment', () => {
const href = `${basePath}/app/kibana#/discover/New-Saved-Search`;
expect(isInternalURL(href, basePath)).toBe(true);
});

it('should return `false` if URL includes a protocol/hostname', () => {
const href = `https://example.com${basePath}/app/kibana`;
expect(isInternalURL(href, basePath)).toBe(false);
});

it('should return `false` if URL includes a port', () => {
const href = `http://localhost:5601${basePath}/app/kibana`;
expect(isInternalURL(href, basePath)).toBe(false);
});

it('should return `false` if URL does not specify protocol', () => {
const hrefWithTwoSlashes = `//${basePath}/app/kibana`;
expect(isInternalURL(hrefWithTwoSlashes)).toBe(false);

const hrefWithThreeSlashes = `///${basePath}/app/kibana`;
expect(isInternalURL(hrefWithThreeSlashes)).toBe(false);
});
}

describe('with basePath defined', () => {
const basePath = '/iqf';

commonTestCases(basePath);

it('should return `true` if URL starts with a basepath', () => {
const href = `${basePath}/login`;
expect(isInternalURL(href, basePath)).toBe(true);
});

it('should return `false` if URL does not start with basePath', () => {
const href = '/notbasepath/app/kibana';
expect(isInternalURL(href, basePath)).toBe(false);
});
});

describe('without basePath defined', () => commonTestCases());
});
26 changes: 26 additions & 0 deletions x-pack/plugins/security/common/is_internal_url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { parse } from 'url';

export function isInternalURL(url: string, basePath = '') {
const { protocol, hostname, port, pathname } = parse(
url,
false /* parseQueryString */,
true /* slashesDenoteHost */
);

// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
// and the first slash that belongs to path.
if (protocol !== null || hostname !== null || port !== null) {
return false;
}

return String(pathname).startsWith(basePath);
}
20 changes: 3 additions & 17 deletions x-pack/plugins/security/common/parse_next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { parse } from 'url';
import { isInternalURL } from './is_internal_url';

export function parseNext(href: string, basePath = '') {
const { query, hash } = parse(href, true);
Expand All @@ -20,23 +21,8 @@ export function parseNext(href: string, basePath = '') {
}

// validate that `next` is not attempting a redirect to somewhere
// outside of this Kibana install
const { protocol, hostname, port, pathname } = parse(
next,
false /* parseQueryString */,
true /* slashesDenoteHost */
);

// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
// and the first slash that belongs to path.
if (protocol !== null || hostname !== null || port !== null) {
return `${basePath}/`;
}

if (!String(pathname).startsWith(basePath)) {
// outside of this Kibana install.
if (!isInternalURL(next, basePath)) {
return `${basePath}/`;
}

Expand Down
190 changes: 190 additions & 0 deletions x-pack/plugins/security/server/authentication/providers/saml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,131 @@ describe('SAMLAuthenticationProvider', () => {
);
});

describe('IdP initiated login', () => {
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
mockOptions.basePath.get.mockReturnValue(mockOptions.basePath.serverBasePath);

const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.callAsCurrentUser.mockImplementation(() =>
Promise.resolve(mockAuthenticatedUser())
);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);

mockOptions.client.callAsInternalUser.mockResolvedValue({
username: 'user',
access_token: 'valid-token',
refresh_token: 'valid-refresh-token',
});

provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
maxRedirectURLSize: new ByteSizeValue(100),
useRelayStateDeepLink: true,
});
});

it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => {
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
maxRedirectURLSize: new ByteSizeValue(100),
useRelayStateDeepLink: false,
});

await expect(
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
relayState: '/mock-server-basepath/app/some-app#some-deep-link',
})
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/', {
state: {
username: 'user',
accessToken: 'valid-token',
refreshToken: 'valid-refresh-token',
realm: 'test-realm',
},
})
);
});

it('redirects to the home page if `relayState` is not specified.', async () => {
await expect(
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
})
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/', {
state: {
username: 'user',
accessToken: 'valid-token',
refreshToken: 'valid-refresh-token',
realm: 'test-realm',
},
})
);
});

it('redirects to the home page if `relayState` includes external URL', async () => {
await expect(
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
relayState: 'https://evil.com/mock-server-basepath/app/some-app#some-deep-link',
})
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/', {
state: {
username: 'user',
accessToken: 'valid-token',
refreshToken: 'valid-refresh-token',
realm: 'test-realm',
},
})
);
});

it('redirects to the home page if `relayState` includes URL that starts with double slashes', async () => {
await expect(
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
relayState: '//mock-server-basepath/app/some-app#some-deep-link',
})
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/', {
state: {
username: 'user',
accessToken: 'valid-token',
refreshToken: 'valid-refresh-token',
realm: 'test-realm',
},
})
);
});

it('redirects to the URL from the relay state.', async () => {
await expect(
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
relayState: '/mock-server-basepath/app/some-app#some-deep-link',
})
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', {
state: {
username: 'user',
accessToken: 'valid-token',
refreshToken: 'valid-refresh-token',
realm: 'test-realm',
},
})
);
});
});

describe('IdP initiated login with existing session', () => {
it('returns `notHandled` if new SAML Response is rejected.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
Expand Down Expand Up @@ -377,6 +502,71 @@ describe('SAMLAuthenticationProvider', () => {
});
});

it(`redirects to the URL from relay state if new SAML Response is for the same user if ${description}.`, async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const state = {
username: 'user',
accessToken: 'existing-token',
refreshToken: 'existing-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;

const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);

mockOptions.client.callAsInternalUser.mockResolvedValue({
username: 'user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
});

mockOptions.tokens.invalidate.mockResolvedValue(undefined);

provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
maxRedirectURLSize: new ByteSizeValue(100),
useRelayStateDeepLink: true,
});

await expect(
provider.login(
request,
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
relayState: '/mock-server-basepath/app/some-app#some-deep-link',
},
state
)
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', {
state: {
username: 'user',
accessToken: 'new-valid-token',
refreshToken: 'new-valid-refresh-token',
realm: 'test-realm',
},
})
);

expectAuthenticateCall(mockOptions.client, { headers: { authorization } });

expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith(
'shield.samlAuthenticate',
{
body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
}
);

expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
});
});

it(`redirects to \`overwritten_session\` if new SAML Response is for the another user if ${description}.`, async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const state = {
Expand Down
Loading

0 comments on commit 85f2076

Please sign in to comment.