Skip to content

Commit

Permalink
[Workplace Search] Persist OAuth token package during OAuth connect f…
Browse files Browse the repository at this point in the history
…low (elastic#93210)

* Store session data sent from Enterprise Search server

This modifies the EnterpriseSearchRequestHandler to remove any data in a
response under the _sessionData key and instead persist it on the server
side.

Ultimately, this data will be persisted in the login session, but for
now we'll just store it in a cookie. elastic#92558

Also uses this functionality to persist Workplace Search's OAuth token
package.

* Only return a modified response body if _sessionData was found

The destructuring I'm doing to remove _sessionData from the response is
breaking routes that currently expect an empty response body. This
change just leaves those response bodies alone.

* Refactor from initial feedback & add tests

* Decrease levity

* Changes from PR feedback

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
James Rucker and kibanamachine committed Mar 9, 2021
1 parent c1f4de8 commit 89557a8
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 6 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/enterprise_search/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,5 @@ export const JSON_HEADER = {
};

export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode';

export const ENTERPRISE_SEARCH_KIBANA_COOKIE = '_enterprise_search';
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@

import { mockConfig, mockLogger } from '../__mocks__';

import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants';
import {
ENTERPRISE_SEARCH_KIBANA_COOKIE,
JSON_HEADER,
READ_ONLY_MODE_HEADER,
} from '../../common/constants';

import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler';

Expand Down Expand Up @@ -171,6 +175,28 @@ describe('EnterpriseSearchRequestHandler', () => {
headers: mockExpectedResponseHeaders,
});
});

it('filters out any _sessionData passed back from Enterprise Search', async () => {
const jsonWithSessionData = {
_sessionData: {
secrets: 'no peeking',
},
regular: 'data',
};

EnterpriseSearchAPI.mockReturn(jsonWithSessionData, { headers: JSON_HEADER });

const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/prep' });
await makeAPICall(requestHandler);

expect(responseMock.custom).toHaveBeenCalledWith({
statusCode: 200,
body: {
regular: 'data',
},
headers: mockExpectedResponseHeaders,
});
});
});
});

Expand Down Expand Up @@ -378,6 +404,33 @@ describe('EnterpriseSearchRequestHandler', () => {
});
});

describe('setSessionData', () => {
it('sets the value of wsOAuthTokenPackage in a cookie', async () => {
const tokenPackage = 'some_encrypted_secrets';

const mockNow = 'Thu, 04 Mar 2021 22:40:32 GMT';
const mockInAnHour = 'Thu, 04 Mar 2021 23:40:32 GMT';
jest.spyOn(global.Date, 'now').mockImplementationOnce(() => {
return new Date(mockNow).valueOf();
});

const sessionDataBody = {
_sessionData: { wsOAuthTokenPackage: tokenPackage },
regular: 'data',
};

EnterpriseSearchAPI.mockReturn(sessionDataBody, { headers: JSON_HEADER });

const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/' });
await makeAPICall(requestHandler);

expect(enterpriseSearchRequestHandler.headers).toEqual({
['set-cookie']: `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}; Path=/; Expires=${mockInAnHour}; SameSite=Lax; HttpOnly`,
...mockExpectedResponseHeaders,
});
});
});

it('isEmptyObj', async () => {
expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true);
expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import {
Logger,
} from 'src/core/server';

import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants';
import {
ENTERPRISE_SEARCH_KIBANA_COOKIE,
JSON_HEADER,
READ_ONLY_MODE_HEADER,
} from '../../common/constants';

import { ConfigType } from '../index';

interface ConstructorDependencies {
Expand Down Expand Up @@ -113,11 +118,17 @@ export class EnterpriseSearchRequestHandler {
return this.handleInvalidDataError(response, url, json);
}

// Intercept data that is meant for the server side session
const { _sessionData, ...responseJson } = json;
if (_sessionData) {
this.setSessionData(_sessionData);
}

// Pass successful responses back to the front-end
return response.custom({
statusCode: status,
headers: this.headers,
body: json,
body: _sessionData ? responseJson : json,
});
} catch (e) {
// Catch connection/auth errors
Expand Down Expand Up @@ -270,6 +281,27 @@ export class EnterpriseSearchRequestHandler {
this.headers[READ_ONLY_MODE_HEADER] = readOnlyMode as 'true' | 'false';
}

/**
* Extract Session Data
*
* In the future, this will set the keys passed back from Enterprise Search
* into the Kibana login session.
* For now we'll explicity look for the Workplace Search OAuth token package
* and stuff it into a cookie so it can be picked up later when we proxy the
* OAuth callback.
*/
setSessionData(sessionData: { [key: string]: string }) {
if (sessionData.wsOAuthTokenPackage) {
const anHourFromNow = new Date(Date.now());
anHourFromNow.setHours(anHourFromNow.getHours() + 1);

const cookiePayload = `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${sessionData.wsOAuthTokenPackage};`;
const cookieRestrictions = `Path=/; Expires=${anHourFromNow.toUTCString()}; SameSite=Lax; HttpOnly`;

this.headers['set-cookie'] = `${cookiePayload} ${cookieRestrictions}`;
}
}

/**
* Misc helpers
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../common/constants';

import { getOAuthTokenPackageParams } from './get_oauth_token_package_params';

describe('getOAuthTokenPackage', () => {
const tokenPackage = 'some_encrypted_secrets';
const tokenPackageCookie = `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}`;
const tokenPackageParams = { token_package: tokenPackage };

describe('when there are no cookie headers', () => {
it('returns an empty parameter set', () => {
expect(getOAuthTokenPackageParams(undefined)).toEqual({});
});
});

describe('when there is a single cookie header', () => {
it('returns an empty parameter set when our cookie is not there', () => {
const cookieHeader = '_st_fruit=banana';

expect(getOAuthTokenPackageParams(cookieHeader)).toEqual({});
});

it('returns the token package when our cookie is the only one', () => {
const cookieHeader = `${tokenPackageCookie}`;

expect(getOAuthTokenPackageParams(cookieHeader)).toEqual(tokenPackageParams);
});

it('returns the token package when there are other cookies in the header', () => {
const cookieHeader = `_chocolate=chip; ${tokenPackageCookie}; _oatmeal=raisin`;

expect(getOAuthTokenPackageParams(cookieHeader)).toEqual(tokenPackageParams);
});
});

describe('when there are multiple cookie headers', () => {
it('returns an empty parameter set when none of them include our cookie', () => {
const cookieHeaders = ['_st_fruit=banana', '_sid=12345'];

expect(getOAuthTokenPackageParams(cookieHeaders)).toEqual({});
});

it('returns the token package when our cookie is present', () => {
const cookieHeaders = ['_st_fruit=banana', `_heat=spicy; ${tokenPackageCookie}`];

expect(getOAuthTokenPackageParams(cookieHeaders)).toEqual(tokenPackageParams);
});
});
});
Original file line number Diff line number Diff line change
@@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../common/constants';

export const getOAuthTokenPackageParams = (rawCookieHeader: string | string[] | undefined) => {
// In the future the token package will be stored in the login session. For now it's in a cookie.

if (!rawCookieHeader) {
return {};
}

/**
* A request can have multiple cookie headers and each header can hold multiple cookies.
* Within a header, cookies are separated by '; '. Here we are splitting out the individual
* cookies from the header(s) and looking for the specific one that holds our token package.
*/

const cookieHeaders = Array.isArray(rawCookieHeader) ? rawCookieHeader : [rawCookieHeader];

let tokenPackage: string | undefined;

cookieHeaders
.flatMap((rawHeader) => rawHeader.split('; '))
.forEach((rawCookie) => {
const [cookieName, cookieValue] = rawCookie.split('=');
if (cookieName === ENTERPRISE_SEARCH_KIBANA_COOKIE) tokenPackage = cookieValue;
});

if (tokenPackage) {
return { token_package: tokenPackage };
} else {
return {};
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__';

import { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../../common/constants';

import {
registerAccountSourcesRoute,
registerAccountSourcesStatusRoute,
Expand Down Expand Up @@ -1249,6 +1251,15 @@ describe('sources routes', () => {
});

describe('GET /api/workplace_search/sources/create', () => {
const tokenPackage = 'some_encrypted_secrets';

const mockRequest = {
headers: {
authorization: 'BASIC 123',
cookie: `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}`,
},
};

let mockRouter: MockRouter;

beforeEach(() => {
Expand All @@ -1265,8 +1276,11 @@ describe('sources routes', () => {
});

it('creates a request handler', () => {
mockRouter.callRoute(mockRequest as any);

expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/sources/create',
params: { token_package: tokenPackage },
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import { schema } from '@kbn/config-schema';

import { getOAuthTokenPackageParams } from '../../lib/get_oauth_token_package_params';

import { RouteDependencies } from '../../plugin';

const schemaValuesSchema = schema.recordOf(
Expand Down Expand Up @@ -862,9 +864,12 @@ export function registerOauthConnectorParamsRoute({
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/sources/create',
})
async (context, request, response) => {
return enterpriseSearchRequestHandler.createRequest({
path: '/ws/sources/create',
params: getOAuthTokenPackageParams(request.headers.cookie),
})(context, request, response);
}
);
}

Expand Down

0 comments on commit 89557a8

Please sign in to comment.