[Workplace Search] Persist OAuth token package during OAuth connect flow (#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. https://github.com/elastic/kibana/issues/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>
This commit is contained in:
James Rucker 2021-03-08 16:17:35 -08:00 committed by GitHub
parent 0ce3dc7f10
commit add02f13e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 206 additions and 6 deletions

View file

@ -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';

View file

@ -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';
@ -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,
});
});
});
});
@ -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);

View file

@ -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 {
@ -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
@ -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
*/

View file

@ -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);
});
});
});

View file

@ -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 {};
}
};

View file

@ -7,6 +7,8 @@
import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__';
import { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../../common/constants';
import {
registerAccountSourcesRoute,
registerAccountSourcesStatusRoute,
@ -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(() => {
@ -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 },
});
});
});

View file

@ -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(
@ -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);
}
);
}