Skip to content

Commit

Permalink
Revamp client-side session idle timeout
Browse files Browse the repository at this point in the history
Session timeout component now communicates with the new security
APIs -- /session/info and /session/extend -- to get session state,
adjust timers, and extend the session. The component also now
communicates across tabs with a BroadcastChannel, using a library
to support older browsers.
Session idle timeout functionality behaves similarly from an end
user experience.
  • Loading branch information
jportner committed Nov 1, 2019
1 parent baaf3ae commit a2b24ca
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 129 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"bluebird": "3.5.5",
"boom": "^7.2.0",
"brace": "0.11.1",
"broadcast-channel": "^2.3.2",
"cache-loader": "^4.1.0",
"chalk": "^2.4.2",
"check-disk-space": "^2.1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ module.config(($httpProvider) => {
function interceptorFactory(responseHandler) {
return function interceptor(response) {
if (!isUnauthenticated && !isSystemApiRequest(response.config)) {
npSetup.plugins.security.sessionTimeout.extend();
npSetup.plugins.security.sessionTimeout.extend(response.config.url);
}
return responseHandler(response);
};
Expand Down
10 changes: 3 additions & 7 deletions x-pack/plugins/security/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,16 @@ import {

export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPluginStart> {
public setup(core: CoreSetup) {
const { http, notifications, injectedMetadata } = core;
const { http, notifications } = core;
const { basePath, anonymousPaths } = http;
anonymousPaths.register('/login');
anonymousPaths.register('/logout');
anonymousPaths.register('/logged_out');

const sessionExpired = new SessionExpired(basePath);
http.intercept(new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths));
const sessionTimeout = new SessionTimeout(
injectedMetadata.getInjectedVar('session.idleTimeout', null) as number | null,
notifications,
sessionExpired,
http
);
const sessionTimeout = new SessionTimeout(notifications, sessionExpired, http);
sessionTimeout.init();
http.intercept(new SessionTimeoutHttpInterceptor(sessionTimeout, anonymousPaths));

return {
Expand Down
289 changes: 191 additions & 98 deletions x-pack/plugins/security/public/session/session_timeout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { coreMock } from 'src/core/public/mocks';
import BroadcastChannel from 'broadcast-channel';
import { SessionTimeout } from './session_timeout';
import { createSessionExpiredMock } from './session_expired.mock';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
Expand Down Expand Up @@ -44,128 +45,220 @@ const expectWarningToastHidden = (
expect(notifications.toasts.remove).toHaveBeenCalledWith(toast);
};

describe('warning toast', () => {
test(`shows session expiration warning toast`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
describe('Session Timeout', () => {
const now = new Date().getTime();
const defaultSessionInfo = { now, expires: now + 2 * 60 * 1000, maxExpires: null };
let notifications: ReturnType<typeof coreMock.createSetup>['notifications'];
let http: ReturnType<typeof coreMock.createSetup>['http'];
let sessionExpired: ReturnType<typeof createSessionExpiredMock>;
let sessionTimeout: SessionTimeout;
const toast = Symbol();

sessionTimeout.extend();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);
beforeAll(() => {
BroadcastChannel.enforceOptions({
type: 'simulate',
});
});

test(`extend delays the warning toast`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
beforeEach(() => {
const setup = coreMock.createSetup();
notifications = setup.notifications;
http = setup.http;
notifications.toasts.add.mockReturnValue(toast as any);
sessionExpired = createSessionExpiredMock();
sessionTimeout = new SessionTimeout(notifications, sessionExpired, http);

sessionTimeout.extend();
jest.advanceTimersByTime(54 * 1000);
expectNoWarningToast(notifications);
// default mocked response for checking session info
http.fetch.mockResolvedValue(defaultSessionInfo);

sessionTimeout.extend();
jest.advanceTimersByTime(54 * 1000);
expectNoWarningToast(notifications);
// the session timeout class uses a BroadcastChannel to communicate across tabs;
// we have to essentially disable part of this process so the tests will complete
// eslint-disable-next-line dot-notation
const elector = sessionTimeout['elector'];
jest.spyOn(elector, 'awaitLeadership').mockImplementation(() => Promise.resolve());
});

jest.advanceTimersByTime(1 * 1000);
afterEach(async () => {
jest.clearAllMocks();
});

expectWarningToast(notifications);
afterAll(() => {
BroadcastChannel.enforceOptions(null);
});

test(`extend hides displayed warning toast`, () => {
const { notifications, http } = coreMock.createSetup();
const toast = Symbol();
notifications.toasts.add.mockReturnValue(toast as any);
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
describe('warning toast', () => {
test(`shows session expiration warning toast`, async () => {
await sessionTimeout.init();

sessionTimeout.extend();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);
});

sessionTimeout.extend();
expectWarningToastHidden(notifications, toast);
});
test(`extend only results in an HTTP call if a warning is shown`, async () => {
await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalledTimes(1);

test('clicking "extend" causes a new HTTP request (which implicitly extends the session)', () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);

sessionTimeout.extend();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);

expect(http.get).not.toHaveBeenCalled();
const toastInput = notifications.toasts.add.mock.calls[0][0];
expect(toastInput).toHaveProperty('text');
const reactComponent = (toastInput as any).text;
const wrapper = mountWithIntl(reactComponent);
wrapper.find('EuiButton[data-test-subj="refreshSessionButton"]').simulate('click');
expect(http.get).toHaveBeenCalled();
});
await sessionTimeout.extend('/foo');
expect(http.fetch).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(54 * 1000);
expectNoWarningToast(notifications);

test('when the session timeout is shorter than 65 seconds, display the warning immediately and for a shorter duration', () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(64 * 1000, notifications, sessionExpired, http);
await sessionTimeout.extend('/foo');
expect(http.fetch).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(10 * 1000);
expectWarningToast(notifications);

sessionTimeout.extend();
jest.advanceTimersByTime(0);
expectWarningToast(notifications, 59 * 1000);
});
});
http.fetch.mockResolvedValue({
now: now + 55 * 1000,
expires: now + 55 * 1000 + 2 * 60 * 1000,
maxExpires: null,
});
await sessionTimeout.extend('/foo');
expect(http.fetch).toHaveBeenCalledTimes(2);
});

describe('session expiration', () => {
test(`expires the session 5 seconds before it really expires`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
test(`extend hides displayed warning toast`, async () => {
await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalledTimes(1);

sessionTimeout.extend();
jest.advanceTimersByTime(114 * 1000);
expect(sessionExpired.logout).not.toHaveBeenCalled();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
const elapsed = 55 * 1000;
jest.advanceTimersByTime(elapsed);
expectWarningToast(notifications);

jest.advanceTimersByTime(1 * 1000);
expect(sessionExpired.logout).toHaveBeenCalled();
});
http.fetch.mockResolvedValue({
now: now + elapsed,
expires: now + elapsed + 2 * 60 * 1000,
maxExpires: null,
});
await sessionTimeout.extend('/foo');
expect(http.fetch).toHaveBeenCalledTimes(2);
expectWarningToastHidden(notifications, toast);
});

test(`extend delays the expiration`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http);
test(`extend does nothing for session-related routes`, async () => {
await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalledTimes(1);

sessionTimeout.extend();
jest.advanceTimersByTime(114 * 1000);
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
const elapsed = 55 * 1000;
jest.advanceTimersByTime(elapsed);
expectWarningToast(notifications);

sessionTimeout.extend();
jest.advanceTimersByTime(114 * 1000);
expect(sessionExpired.logout).not.toHaveBeenCalled();
await sessionTimeout.extend('/api/security/session/info');
expect(http.fetch).toHaveBeenCalledTimes(1);

jest.advanceTimersByTime(1 * 1000);
expect(sessionExpired.logout).toHaveBeenCalled();
});
await sessionTimeout.extend('/api/security/session/extend');
expect(http.fetch).toHaveBeenCalledTimes(1);
});

test(`the "leader" will check for updated session info before the warning displays`, async () => {
// eslint-disable-next-line dot-notation
const elector = sessionTimeout['elector'];
Object.defineProperty(elector, 'isLeader', {
get: jest.fn(() => true),
});

await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalledTimes(1);

// the leader checks for updated session info 1 second before the warning is shown
const elapsed = 54 * 1000;
jest.advanceTimersByTime(elapsed);
expect(http.fetch).toHaveBeenCalledTimes(2);
});

test(`any non-leader will *not* check for updated session info before the warning displays`, async () => {
await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalledTimes(1);

// the leader checks for updated session info 1 second before the warning is shown
const elapsed = 54 * 1000;
jest.advanceTimersByTime(elapsed);
expect(http.fetch).toHaveBeenCalledTimes(1);
});

test(`if the session timeout is shorter than 5 seconds, expire session immediately`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(4 * 1000, notifications, sessionExpired, http);
test('clicking "extend" causes a new HTTP request (which implicitly extends the session)', async () => {
await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalledTimes(1);

sessionTimeout.extend();
jest.advanceTimersByTime(0);
expect(sessionExpired.logout).toHaveBeenCalled();
// we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires
jest.advanceTimersByTime(55 * 1000);
expectWarningToast(notifications);

const toastInput = notifications.toasts.add.mock.calls[0][0];
expect(toastInput).toHaveProperty('text');
const reactComponent = (toastInput as any).text;
const wrapper = mountWithIntl(reactComponent);
wrapper.find('EuiButton[data-test-subj="refreshSessionButton"]').simulate('click');
expect(http.fetch).toHaveBeenCalledTimes(2);
});

test('when the session timeout is shorter than 65 seconds, display the warning immediately and for a shorter duration', async () => {
http.fetch.mockResolvedValue({ now, expires: now + 64 * 1000, maxExpires: null });
await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalled();

jest.advanceTimersByTime(0);
expectWarningToast(notifications, 59 * 1000);
});
});

test(`'null' sessionTimeout never logs you out`, () => {
const { notifications, http } = coreMock.createSetup();
const sessionExpired = createSessionExpiredMock();
const sessionTimeout = new SessionTimeout(null, notifications, sessionExpired, http);
sessionTimeout.extend();
jest.advanceTimersByTime(Number.MAX_VALUE);
expect(sessionExpired.logout).not.toHaveBeenCalled();
describe('session expiration', () => {
test(`expires the session 5 seconds before it really expires`, async () => {
await sessionTimeout.init();

jest.advanceTimersByTime(114 * 1000);
expect(sessionExpired.logout).not.toHaveBeenCalled();

jest.advanceTimersByTime(1 * 1000);
expect(sessionExpired.logout).toHaveBeenCalled();
});

test(`extend delays the expiration`, async () => {
await sessionTimeout.init();
expect(http.fetch).toHaveBeenCalledTimes(1);

const elapsed = 114 * 1000;
jest.advanceTimersByTime(elapsed);
expectWarningToast(notifications);

const sessionInfo = {
now: now + elapsed,
expires: now + elapsed + 2 * 60 * 1000,
maxExpires: null,
};
http.fetch.mockResolvedValue(sessionInfo);
await sessionTimeout.extend('/foo');
expect(http.fetch).toHaveBeenCalledTimes(2);
// eslint-disable-next-line dot-notation
expect(sessionTimeout['sessionInfo']).toEqual(sessionInfo);

// at this point, the session is good for another 120 seconds
jest.advanceTimersByTime(114 * 1000);
expect(sessionExpired.logout).not.toHaveBeenCalled();

// because "extend" results in an async request and HTTP call, there is a slight delay when timers are updated
// so we need an extra 100ms of padding for this test to ensure that logout has been called
jest.advanceTimersByTime(1 * 1000 + 100);
expect(sessionExpired.logout).toHaveBeenCalled();
});

test(`if the session timeout is shorter than 5 seconds, expire session immediately`, async () => {
http.fetch.mockResolvedValue({ now, expires: now + 4 * 1000, maxExpires: null });
await sessionTimeout.init();

jest.advanceTimersByTime(0);
expect(sessionExpired.logout).toHaveBeenCalled();
});

test(`'null' sessionTimeout never logs you out`, async () => {
http.fetch.mockResolvedValue({ now, expires: null, maxExpires: null });
await sessionTimeout.init();

jest.advanceTimersByTime(Number.MAX_VALUE);
expect(sessionExpired.logout).not.toHaveBeenCalled();
});
});
});
Loading

0 comments on commit a2b24ca

Please sign in to comment.