Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NP Security HTTP Interceptors #39477

Merged
merged 54 commits into from Oct 24, 2019
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
d4f7e8e
We have a NP plugin! :celebration:
kobelb Jun 21, 2019
3ebd52f
Redirecting to login on all 401s
kobelb Jun 21, 2019
16c8e29
Merge remote-tracking branch 'upstream/master' into np/security-http-…
kobelb Jul 29, 2019
7c9f4b8
Adding commented out code for when credentials are omitted
kobelb Jul 30, 2019
249bbfa
Merge remote-tracking branch 'upstream/master' into np/security-http-…
kobelb Jul 30, 2019
9041c15
Fixing types
kobelb Jul 31, 2019
2c6b3aa
Respond 403 when user changes password with incorrect current password
kobelb Aug 7, 2019
007bc82
Adding AnonymousPaths where we ignore all 401s
kobelb Aug 7, 2019
67af74c
Adding anonymous path tests
kobelb Aug 7, 2019
984bf56
Extracted a dedicated SessionExpires class and added tests
kobelb Aug 9, 2019
f672b72
Fixing plugin after refactoring to add SessionExpired
kobelb Aug 12, 2019
c7ff2f0
Beginning to work on the session timeout interceptor
kobelb Aug 12, 2019
181d951
Fixing UnauthorizedResponseInterceptor anonymous path test
kobelb Aug 12, 2019
ba84476
Removing test anonymous path
kobelb Aug 14, 2019
a551a5e
Trying to improve readability
kobelb Aug 14, 2019
643729a
Merge remote-tracking branch 'upstream/master' into np/security-http-…
kobelb Aug 14, 2019
32d9385
Displaying session logout warning
kobelb Aug 15, 2019
824086c
Mocking out the base path
kobelb Aug 19, 2019
0384182
Revert "Mocking out the base path"
kobelb Aug 19, 2019
acb61ee
Changing coreMock to use a concrete instance of BasePath
kobelb Aug 19, 2019
41cbc8c
Adding session timeout interceptor tests
kobelb Aug 21, 2019
147edbd
Adding session timeout tests
kobelb Aug 21, 2019
ddab529
Adding more tests for short session timeouts
kobelb Aug 21, 2019
3b8711e
Moving some files to a session folder
kobelb Aug 21, 2019
f37cf88
More thrashing around: renaming and reorganizing
kobelb Aug 21, 2019
93206ad
Renaming Interceptor to HttpInterceptor
kobelb Aug 21, 2019
eb860e8
Merge remote-tracking branch 'upstream/master' into np/security-http-…
kobelb Sep 25, 2019
3bd02fb
Fixing some type errors
kobelb Sep 25, 2019
bae2ef9
Fixing legacy chrome API tests
kobelb Sep 25, 2019
da8b846
Fixing other tests to use the concrete instance of BasePath
kobelb Sep 25, 2019
fb00dc4
Adjusting some types
kobelb Sep 25, 2019
51bdb02
Putting DeeplyMocked back, I don't get how DeeplyMockedKeys works
kobelb Sep 25, 2019
837ba11
Merge remote-tracking branch 'upstream/master' into np/security-http-…
kobelb Oct 18, 2019
e23cf8f
Moving anonymousPaths to public core http
kobelb Oct 18, 2019
2c98227
Reading sessionTimeout from injected vars and supporting null timeout
kobelb Oct 18, 2019
32751a4
Doesn't extend session when there is no response
kobelb Oct 18, 2019
4b0ea10
Updating docs and snapshots
kobelb Oct 18, 2019
9432988
Casting sessionTimeout injectedVar to "number | null"
kobelb Oct 21, 2019
e169fbe
Fixing i18n issues
kobelb Oct 21, 2019
3582111
Merge branch 'master' into np/security-http-interceptors
elasticmachine Oct 21, 2019
edac0ec
Update x-pack/plugins/security/public/plugin.ts
kobelb Oct 22, 2019
85a655e
Adding milliseconds postfix to SessionTimeout private fields
kobelb Oct 22, 2019
a70ac40
Even better anonymous paths, with some validation
kobelb Oct 22, 2019
248c580
Adjusting public method docs for IAnonymousPaths
kobelb Oct 22, 2019
5697aa2
Adjusting spelling of base-path to basePath
kobelb Oct 22, 2019
4af0d8a
Update x-pack/plugins/security/public/session/session_timeout.tsx
kobelb Oct 22, 2019
5a91232
Update src/core/public/http/anonymous_paths.ts
kobelb Oct 22, 2019
50237cb
Update src/core/public/http/anonymous_paths.ts
kobelb Oct 22, 2019
40a3697
AnonymousPaths implements IAnonymousPaths and uses IBasePath
kobelb Oct 22, 2019
08aa526
Removing DeeplyMocked
kobelb Oct 22, 2019
c4ec25c
Removing TODOs
kobelb Oct 22, 2019
15ff621
Fixing types...
kobelb Oct 22, 2019
5a9b9a5
Now, ever more normal
kobelb Oct 23, 2019
43425b1
Merge remote-tracking branch 'upstream/master' into np/security-http-…
kobelb Oct 23, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions src/core/public/index.ts
Expand Up @@ -102,6 +102,7 @@ export {
HttpResponse,
HttpHandler,
HttpBody,
HttpInterceptController,
} from './http';

/**
Expand Down
2 changes: 2 additions & 0 deletions x-pack/dev-tools/jest/setup/polyfills.js
Expand Up @@ -13,3 +13,5 @@ bluebird.Promise.setScheduler(function (fn) { global.setImmediate.call(global, f

const MutationObserver = require('mutation-observer');
Object.defineProperty(window, 'MutationObserver', { value: MutationObserver });

require('whatwg-fetch');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already part of the OSS jest setup, but it wasn't here.

Expand Up @@ -314,7 +314,7 @@ export class ChangePasswordForm extends Component<Props, State> {
};

private handleChangePasswordFailure = (error: Record<string, any>) => {
if (error.body && error.body.statusCode === 401) {
if (error.body && error.body.statusCode === 403) {
this.setState({ currentPasswordError: true });
} else {
toastNotifications.addDanger(
Expand Down
56 changes: 3 additions & 53 deletions x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js
Expand Up @@ -4,81 +4,31 @@
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { uiModules } from 'ui/modules';
import { isSystemApiRequest } from 'ui/system_api';
import { Path } from 'plugins/xpack_main/services/path';
import { toastNotifications } from 'ui/notify';
import 'plugins/security/services/auto_logout';
import { SessionExpirationWarning } from '../components/session_expiration_warning';
import { npSetup } from 'ui/new_platform';

/**
* Client session timeout is decreased by this number so that Kibana server
* can still access session content during logout request to properly clean
* user session up (invalidate access tokens, redirect to logout portal etc.).
* @type {number}
*/
const SESSION_TIMEOUT_GRACE_PERIOD_MS = 5000;

const module = uiModules.get('security', []);
module.config(($httpProvider) => {
$httpProvider.interceptors.push((
$timeout,
$q,
$injector,
sessionTimeout,
Private,
autoLogout
) => {

function refreshSession() {
// Make a simple request to keep the session alive
$injector.get('es').ping();
clearNotifications();
}

const isUnauthenticated = Path.isUnauthenticated();
const notificationLifetime = 60 * 1000;
const notificationOptions = {
color: 'warning',
text: (
<SessionExpirationWarning onRefreshSession={refreshSession} />
),
title: i18n.translate('xpack.security.hacks.warningTitle', {
defaultMessage: 'Warning'
}),
toastLifeTimeMs: Math.min(
(sessionTimeout - SESSION_TIMEOUT_GRACE_PERIOD_MS),
notificationLifetime
),
};

let pendingNotification;
let activeNotification;
let pendingSessionExpiration;

function clearNotifications() {
if (pendingNotification) $timeout.cancel(pendingNotification);
if (pendingSessionExpiration) clearTimeout(pendingSessionExpiration);
if (activeNotification) toastNotifications.remove(activeNotification);
}

function scheduleNotification() {
pendingNotification = $timeout(showNotification, Math.max(sessionTimeout - notificationLifetime, 0));
}

function showNotification() {
activeNotification = toastNotifications.add(notificationOptions);
pendingSessionExpiration = setTimeout(() => autoLogout(), notificationOptions.toastLifeTimeMs);
}

function interceptorFactory(responseHandler) {
return function interceptor(response) {
if (!isUnauthenticated && !isSystemApiRequest(response.config) && sessionTimeout !== null) {
clearNotifications();
scheduleNotification();
if (!isUnauthenticated && !isSystemApiRequest(response.config)) {
npSetup.plugins.security.sessionTimeout.extend();
}
return responseHandler(response);
};
Expand Down
Expand Up @@ -81,7 +81,7 @@ describe('User routes', () => {
);
});

it('returns 401 if old password is wrong.', async () => {
it('returns 403 if old password is wrong.', async () => {
getUserStub.returns(Promise.reject(new Error('Something went wrong.')));

return changePasswordRoute
Expand All @@ -90,14 +90,14 @@ describe('User routes', () => {
sinon.assert.notCalled(clusterStub.callWithRequest);
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
statusCode: 401,
error: 'Unauthorized',
statusCode: 403,
error: 'Forbidden',
message: 'Something went wrong.'
});
});
});

it('returns 401 if user can authenticate with new password.', async () => {
it(`returns 401 if user can't authenticate with new password.`, async () => {
getUserStub.returns(Promise.resolve({}));

loginStub
Expand Down
Expand Up @@ -95,7 +95,7 @@ export function initUsersApi({ authc: { login }, config }, server) {
BasicCredentials.decorateRequest(request, username, password)
);
} catch(err) {
throw Boom.unauthorized(err);
throw Boom.forbidden(err);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this was changed from return to throw? I forget the distinction as it relates to Hapi, but we should probably make this consistent throughout this file (we return in most other places)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either work in the LP, the significant part was the switch from unauthorized to forbidden. I can make this consistent with the rest of the file though.

}
}

Expand Down
1 change: 1 addition & 0 deletions x-pack/package.json
Expand Up @@ -175,6 +175,7 @@
"ts-loader": "^6.0.4",
"typescript": "3.5.3",
"vinyl-fs": "^3.0.2",
"whatwg-fetch": "^3.0.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to add this as a dependency when it already exists in the root package.json? I was under the impression that we didn't need to duplicate between these two package files (react, for example, doesn't exist in this package.json, although it's clearly a dependency)

I misread this file, turns out we duplicate dependencies.

"xml-crypto": "^0.10.1",
"yargs": "4.8.1"
},
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/security/kibana.json
Expand Up @@ -4,5 +4,5 @@
"kibanaVersion": "kibana",
"configPath": ["xpack", "security"],
"server": true,
"ui": false
"ui": true
}
31 changes: 31 additions & 0 deletions x-pack/plugins/security/public/anonymous_paths.test.ts
@@ -0,0 +1,31 @@
/*
* 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 { AnonymousPaths } from './anonymous_paths';
import { BasePath } from 'src/core/public/http/base_path_service';

describe('#isAnonymous', () => {
it('returns true for initialPaths', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath, ['/bar', '/baz']);
expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true);
expect(anonymousPaths.isAnonymous('/foo/baz')).toBe(true);
});

it('returns true for registered paths', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath, []);
anonymousPaths.register('/bar');
expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true);
});

it('returns false for other paths', () => {
const basePath = new BasePath('/foo');
const anonymousPaths = new AnonymousPaths(basePath, ['/bar', '/baz']);
anonymousPaths.register('/qux');
expect(anonymousPaths.isAnonymous('/foo/quux')).toBe(false);
});
});
23 changes: 23 additions & 0 deletions x-pack/plugins/security/public/anonymous_paths.ts
@@ -0,0 +1,23 @@
/*
* 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 { HttpSetup } from 'src/core/public';

export class AnonymousPaths {
private paths: Set<string>;

constructor(private basePath: HttpSetup['basePath'], initialAnonymousPaths: string[]) {
this.paths = new Set(initialAnonymousPaths);
}

public isAnonymous(path: string): boolean {
return this.paths.has(this.basePath.remove(path));
}

public register(path: string) {
this.paths.add(path);
}
}
11 changes: 11 additions & 0 deletions x-pack/plugins/security/public/index.ts
@@ -0,0 +1,11 @@
/*
* 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 { PluginInitializer } from 'src/core/public';
import { SecurityPlugin, SecurityPluginSetup, SecurityPluginStart } from './plugin';

export const plugin: PluginInitializer<SecurityPluginSetup, SecurityPluginStart> = () =>
new SecurityPlugin();
39 changes: 39 additions & 0 deletions x-pack/plugins/security/public/plugin.ts
@@ -0,0 +1,39 @@
/*
* 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 { Plugin, CoreSetup } from 'src/core/public';
import { AnonymousPaths } from './anonymous_paths';
import { SessionExpired } from './session_expired';
import { SessionTimeout } from './session_timeout';
import { SessionTimeoutInterceptor } from './session_timeout_interceptor';
import { UnauthorizedResponseInterceptor } from './unauthorized_response_interceptor';

export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPluginStart> {
public setup(core: CoreSetup, deps: {}) {
kobelb marked this conversation as resolved.
Show resolved Hide resolved
const { http, notifications } = core;
const sessionExpired = new SessionExpired(http.basePath);
const anonymousPaths = new AnonymousPaths(http.basePath, [
'/login',
'/logout',
'/logged_out',
'/status',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd much prefer to not have /status hard-coded here, but OSS can't call security.anonymousPaths.register...

Copy link
Member

@azasypkin azasypkin Aug 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, core (server side at least) has a notion of authentication, why can't anonymousePaths be exposed by the core instead? Also I'm curious if this path should be added into anonymousePaths if status.allowAnonymous is set to false (and security plugin shouldn't have access to this config value ideally)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point! I'll open up a separate PR to add this directly to the NP to see how they feel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving anonymous paths to the NP is turning into a bit of work. before I continue down this path, @elastic/kibana-platform do you mind confirming whether or not it makes sense to move this to the NP? Also, do you think it makes sense for it to be it's own "top-level service" or integrated within one of the existing services?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make sense to be on the frontend's HTTP service. The data flow would probably be something like:

  • Core's backend HTTP service exposes a readonly Set of paths
  • the ui_render_mixin in the legacy platform serializes this set into the injected vars provided to the New Platform. We something similar already with plugins here.
  • Core's frontend InjectedMetadataService would expose this list
  • Core's frontend HTTPService would read from InjectedMetadataService and expose to plugins (InjectedMetadataService is not exposed to plugins on purpose).

Once the ui_render_mixin is moved to the New Platform we could eliminate some of the threading here, but until then this probably makes the most sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, these are "frontend paths" where we ignore all 401s. For example the login page which is available at /login, the front-end http client ignores all 401s whenever the user is on this page. This makes me think that plugins should be registering their anonymous paths with the frontend http service during the setup lifecycle.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I understand now.

I think since this behavior (handling of 401s) is only used by Security (I think?) then wouldn't it be fine to keep this in the Security plugin and have the Security plugin register any OSS-owned paths itself? This is similar to what we do with the features plugin for registering features.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is essentially replacing this LP AngularJS service: https://github.com/elastic/kibana/blob/7ac8e4d9ccc2a9e064d8a5621cd62453d1006164/x-pack/legacy/plugins/xpack_main/public/services/path.js. The LP service has a hard-coded list of paths. This means that plugins themselves can't register their own paths, and instead this list must be modified. We've primarily addressed this in the NP version by allowing paths to be registered; however, the /status page is one of these unauthenticated paths. If we keep this NP anonymousPaths service in security, we can't have whatever OSS plugin that provides the status page register the /status route as being anonymous because that'd require OSS to have dependency on an x-pack plugin. This is the primarily reason we're trying to move this service to OSS In the NP.

It's also relevant that security isn't the only plugin which consumes the LP AngularJS service. It's also consumed by telemetry and reporting.

Since the NP core already introduces the concept of "auth" via facilities like core.http.registerAuth, this felt like further validation that moving this to OSS would be appropriate.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's used by other plugins, then I think moving it to the OSS/Core HttpService on the client side makes sense then 👍.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @joshdover!

]);
http.intercept(new UnauthorizedResponseInterceptor(sessionExpired, anonymousPaths));

const sessionTimeout = new SessionTimeout(1.5 * 60 * 1000, notifications, sessionExpired, http);
http.intercept(new SessionTimeoutInterceptor(sessionTimeout, anonymousPaths));

return {
anonymousPaths,
sessionTimeout,
};
}

public start() {}
}

export type SecurityPluginSetup = ReturnType<SecurityPlugin['setup']>;
export type SecurityPluginStart = ReturnType<SecurityPlugin['start']>;
44 changes: 44 additions & 0 deletions x-pack/plugins/security/public/session_expired.test.ts
@@ -0,0 +1,44 @@
/*
* 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 { BasePath } from 'src/core/public/http/base_path_service';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@azasypkin @legrego how do you all feel about us writing tests using the NP core services, instead of mocking them out? When it's painful, or impossible, to use a concrete instance, I definitely think it makes sense to use a mock. However, in situations like these it really isn't that painful, and seems to add a level of additional confidence that the code actually works.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in favor of that approach. I prefer concrete instances when it's not too painful to create or maintain, for the same reasons as you.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both approaches sound good to me too at this stage, but only because of additional confidence that the code actually works point. I'm afraid using real services may not scale very well and we'll end up with a mock in one test and real service in another.

The issue here is that not all core services are exposed to plugins as is, moreover they may have different shape for different life-cycle events: what you can do with HTTP service in setup may be drastically different from what you can do with it in start (e.g. this or this). That means that some of the core services won't be "public" (btw, if I'm not mistaken we're only supposed to import from src/core/public or src/core/public/mocks, I'm surprised that linter doesn't complain about src/core/public/http/base_path_service).

Having said that, core should provide mocks for all of its services and keep them in sync. If we don't have a mock for the core service that is used in a plugin or it's hard to use it in our tests (e.g. it doesn't have reasonable defaults for mock return values) - platform team should treat it as a bug 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid using real services may not scale very well and we'll end up with a mock in one test and real service in another.

I share this fear as well. It seems like a lot of the existing NP tests use the mocks which the NP provides.

The issue here is that not all core services are exposed to plugins as is, moreover they may have different shape for different life-cycle events: what you can do with HTTP service in setup may be drastically different from what you can do with it in start (e.g. this or this). That means that some of the core services won't be "public" (btw, if I'm not mistaken we're only supposed to import from src/core/public or src/core/public/mocks, I'm surprised that linter doesn't complain about src/core/public/http/base_path_service).

It definitely felt like I was reaching "too far" into /src/core/public to get access to this. Do you happen to have a recommendation for where I should be getting the core mock from?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we're supposed to get all the mocks from either src/core/server/mocks or src/core/public/mocks. So for base_path it'd be something like this I guess:

import { coreMock } from 'src/core/public/mocks';
const basePath = coreMock.createSetup().http.basePath;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @azasypkin! I'll change this to use the mocks and reevaluate the other tests as well

Copy link
Contributor Author

@kobelb kobelb Aug 19, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just re-wrote the tests for this using only the mocks, and it's significantly "uglier" and harder to understand... @joshdover how do you feel about us using a concrete instance of BasePath for tests like these?

Concrete instance: https://github.com/elastic/kibana/blob/32d938591cdfbfd3651068e97cc82aa613f66e0c/x-pack/plugins/security/public/session_expired.test.ts
Mocked: https://github.com/elastic/kibana/blob/824086c168aec5cc55c04e5866ceaafdb2ec12f9/x-pack/plugins/security/public/session_expired.test.ts

Copy link
Member

@joshdover joshdover Aug 19, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The basePath APIs are so small and don't interact with anything meaningful. It seems to me that we should replace this with the real implementation in coreMock and make coreMock configurable so you can pass in a basePath when you create the mock.

Normally, I'd say it's bad to mix concrete implementations and mocks like this, but given how little code we're talking about and how common I think this scenario is, I think this mixing would be fine.

In summary: delete the basePath mock completely, replace it with the real implementation, allow coreMock to take a basePath argument.

import { SessionExpired } from './session_expired';

const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url);

it('redirects user to "/logout" when there is no basePath', async () => {
mockCurrentUrl('/foo/bar?baz=quz#quuz');
const sessionExpired = new SessionExpired(new BasePath());
const newUrlPromise = new Promise<string>(resolve => {
jest.spyOn(window.location, 'assign').mockImplementation(url => {
resolve(url);
});
});

sessionExpired.logout();

const url = await newUrlPromise;
expect(url).toBe(
`/logout?next=${encodeURIComponent('/foo/bar?baz=quz#quuz')}&msg=SESSION_EXPIRED`
);
});

it('redirects user to "/${basePath}/logout" and removes basePath from next parameter when there is a basePath', async () => {
mockCurrentUrl('/foo/bar?baz=quz#quuz');
const sessionExpired = new SessionExpired(new BasePath('/foo'));
const newUrlPromise = new Promise<string>(resolve => {
jest.spyOn(window.location, 'assign').mockImplementation(url => {
resolve(url);
});
});

sessionExpired.logout();

const url = await newUrlPromise;
expect(url).toBe(
`/foo/logout?next=${encodeURIComponent('/bar?baz=quz#quuz')}&msg=SESSION_EXPIRED`
);
});
20 changes: 20 additions & 0 deletions x-pack/plugins/security/public/session_expired.ts
@@ -0,0 +1,20 @@
/*
* 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 { HttpSetup } from 'src/core/public';

export class SessionExpired {
constructor(private basePath: HttpSetup['basePath']) {}

public logout() {
const next = this.basePath.remove(
`${window.location.pathname}${window.location.search}${window.location.hash}`
);
window.location.assign(
this.basePath.prepend(`/logout?next=${encodeURIComponent(next)}&msg=SESSION_EXPIRED`)
);
}
}