diff --git a/docs/development/core/server/kibana-plugin-server.authenticationhandler.md b/docs/development/core/server/kibana-plugin-server.authenticationhandler.md index 88d199fc1b536a..1437d5083df2d2 100644 --- a/docs/development/core/server/kibana-plugin-server.authenticationhandler.md +++ b/docs/development/core/server/kibana-plugin-server.authenticationhandler.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type AuthenticationHandler = (request: Readonly, t: AuthToolkit) => AuthResult | Promise; +export declare type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authheaders.md b/docs/development/core/server/kibana-plugin-server.authheaders.md new file mode 100644 index 00000000000000..96939cb8bbcbfb --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authheaders.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthHeaders](./kibana-plugin-server.authheaders.md) + +## AuthHeaders type + +Auth Headers map + +Signature: + +```typescript +export declare type AuthHeaders = Record; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authresultdata.headers.md b/docs/development/core/server/kibana-plugin-server.authresultdata.headers.md new file mode 100644 index 00000000000000..4287978c3ac34a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authresultdata.headers.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultData](./kibana-plugin-server.authresultdata.md) > [headers](./kibana-plugin-server.authresultdata.headers.md) + +## AuthResultData.headers property + +Auth specific headers to authenticate a user against Elasticsearch. + +Signature: + +```typescript +headers: AuthHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authresultdata.md b/docs/development/core/server/kibana-plugin-server.authresultdata.md new file mode 100644 index 00000000000000..7ba5771b80d675 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authresultdata.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultData](./kibana-plugin-server.authresultdata.md) + +## AuthResultData interface + +Result of an incoming request authentication. + +Signature: + +```typescript +export interface AuthResultData +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [headers](./kibana-plugin-server.authresultdata.headers.md) | AuthHeaders | Auth specific headers to authenticate a user against Elasticsearch. | +| [state](./kibana-plugin-server.authresultdata.state.md) | Record<string, unknown> | Data to associate with an incoming request. Any downstream plugin may get access to the data. | + diff --git a/docs/development/core/server/kibana-plugin-server.authresultdata.state.md b/docs/development/core/server/kibana-plugin-server.authresultdata.state.md new file mode 100644 index 00000000000000..3fb8f8e48bdedd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authresultdata.state.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultData](./kibana-plugin-server.authresultdata.md) > [state](./kibana-plugin-server.authresultdata.state.md) + +## AuthResultData.state property + +Data to associate with an incoming request. Any downstream plugin may get access to the data. + +Signature: + +```typescript +state: Record; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md index e28950653b60d7..e8e245ac015974 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md @@ -9,5 +9,5 @@ Authentication is successful with given credentials, allow request to pass throu Signature: ```typescript -authenticated: (state?: object) => AuthResult; +authenticated: (data?: Partial) => AuthResult; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.md index f32f7076f01197..2fe4312153a6ac 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.md @@ -16,7 +16,7 @@ export interface AuthToolkit | Property | Type | Description | | --- | --- | --- | -| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (state?: object) => AuthResult | Authentication is successful with given credentials, allow request to pass through | +| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (data?: Partial<AuthResultData>) => AuthResult | Authentication is successful with given credentials, allow request to pass through | | [redirected](./kibana-plugin-server.authtoolkit.redirected.md) | (url: string) => AuthResult | Authentication requires to interrupt request handling and redirect to a configured url | | [rejected](./kibana-plugin-server.authtoolkit.rejected.md) | (error: Error, options?: {
statusCode?: number;
}) => AuthResult | Authentication is unsuccessful, fail the request with specified error. | diff --git a/docs/development/core/server/kibana-plugin-server.clusterclient.(constructor).md b/docs/development/core/server/kibana-plugin-server.clusterclient.(constructor).md index 82046df278a689..7a920c05156466 100644 --- a/docs/development/core/server/kibana-plugin-server.clusterclient.(constructor).md +++ b/docs/development/core/server/kibana-plugin-server.clusterclient.(constructor).md @@ -9,7 +9,7 @@ Constructs a new instance of the `ClusterClient` class Signature: ```typescript -constructor(config: ElasticsearchClientConfig, log: Logger); +constructor(config: ElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); ``` ## Parameters @@ -18,4 +18,5 @@ constructor(config: ElasticsearchClientConfig, log: Logger); | --- | --- | --- | | config | ElasticsearchClientConfig | | | log | Logger | | +| getAuthHeaders | GetAuthHeaders | | diff --git a/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md b/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md index d0f7a4c93c69dc..d649eab42f086e 100644 --- a/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md +++ b/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md @@ -9,16 +9,14 @@ Creates an instance of `ScopedClusterClient` based on the configuration the curr Signature: ```typescript -asScoped(req?: { - headers?: Headers; - }): ScopedClusterClient; +asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): ScopedClusterClient; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| req | {
headers?: Headers;
} | Request the ScopedClusterClient instance will be scoped to. | +| request | KibanaRequest | LegacyRequest | FakeRequest | Request the ScopedClusterClient instance will be scoped to. Supports request optionality, Legacy.Request & FakeRequest for BWC with LegacyPlatform | Returns: diff --git a/docs/development/core/server/kibana-plugin-server.clusterclient.md b/docs/development/core/server/kibana-plugin-server.clusterclient.md index 89b30379e38b8d..89ed51762198cb 100644 --- a/docs/development/core/server/kibana-plugin-server.clusterclient.md +++ b/docs/development/core/server/kibana-plugin-server.clusterclient.md @@ -16,7 +16,7 @@ export declare class ClusterClient | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(config, log)](./kibana-plugin-server.clusterclient.(constructor).md) | | Constructs a new instance of the ClusterClient class | +| [(constructor)(config, log, getAuthHeaders)](./kibana-plugin-server.clusterclient.(constructor).md) | | Constructs a new instance of the ClusterClient class | ## Properties @@ -28,6 +28,6 @@ export declare class ClusterClient | Method | Modifiers | Description | | --- | --- | --- | -| [asScoped(req)](./kibana-plugin-server.clusterclient.asscoped.md) | | Creates an instance of ScopedClusterClient based on the configuration the current cluster client that exposes additional callAsCurrentUser method scoped to the provided req. Consumers shouldn't worry about closing scoped client instances, these will be automatically closed as soon as the original cluster client isn't needed anymore and closed. | +| [asScoped(request)](./kibana-plugin-server.clusterclient.asscoped.md) | | Creates an instance of ScopedClusterClient based on the configuration the current cluster client that exposes additional callAsCurrentUser method scoped to the provided req. Consumers shouldn't worry about closing scoped client instances, these will be automatically closed as soon as the original cluster client isn't needed anymore and closed. | | [close()](./kibana-plugin-server.clusterclient.close.md) | | Closes the cluster client. After that client cannot be used and one should create a new client instance to be able to interact with Elasticsearch API. | diff --git a/docs/development/core/server/kibana-plugin-server.fakerequest.headers.md b/docs/development/core/server/kibana-plugin-server.fakerequest.headers.md new file mode 100644 index 00000000000000..bd3b6e804d7c08 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.fakerequest.headers.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [FakeRequest](./kibana-plugin-server.fakerequest.md) > [headers](./kibana-plugin-server.fakerequest.headers.md) + +## FakeRequest.headers property + +Headers used for authentication against Elasticsearch + +Signature: + +```typescript +headers: Record; +``` diff --git a/docs/development/core/server/kibana-plugin-server.fakerequest.md b/docs/development/core/server/kibana-plugin-server.fakerequest.md new file mode 100644 index 00000000000000..c95bb9dabae07e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.fakerequest.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [FakeRequest](./kibana-plugin-server.fakerequest.md) + +## FakeRequest interface + +Fake request object created manually by Kibana plugins. + +Signature: + +```typescript +export interface FakeRequest +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [headers](./kibana-plugin-server.fakerequest.headers.md) | Record<string, string> | Headers used for authentication against Elasticsearch | + diff --git a/docs/development/core/server/kibana-plugin-server.getauthheaders.md b/docs/development/core/server/kibana-plugin-server.getauthheaders.md new file mode 100644 index 00000000000000..ee7572615fe1a9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.getauthheaders.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [GetAuthHeaders](./kibana-plugin-server.getauthheaders.md) + +## GetAuthHeaders type + +Get headers to authenticate a user against Elasticsearch. + +Signature: + +```typescript +export declare type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.(constructor).md b/docs/development/core/server/kibana-plugin-server.kibanarequest.(constructor).md index f29493c1e5036f..216859f4351c97 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.(constructor).md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.(constructor).md @@ -9,7 +9,7 @@ Constructs a new instance of the `KibanaRequest` class Signature: ```typescript -constructor(request: Request, params: Params, query: Query, body: Body); +constructor(request: Request, params: Params, query: Query, body: Body, withoutSecretHeaders: boolean); ``` ## Parameters @@ -20,4 +20,5 @@ constructor(request: Request, params: Params, query: Query, body: Body); | params | Params | | | query | Query | | | body | Body | | +| withoutSecretHeaders | boolean | | diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.headers.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.headers.md deleted file mode 100644 index 3c95f91514822c..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.headers.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [headers](./kibana-plugin-server.kibanarequest.headers.md) - -## KibanaRequest.headers property - -Signature: - -```typescript -readonly headers: Headers; -``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.md index a09632febe5315..0a299535e5ec2e 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.md @@ -16,14 +16,13 @@ export declare class KibanaRequestKibanaRequest class | +| [(constructor)(request, params, query, body, withoutSecretHeaders)](./kibana-plugin-server.kibanarequest.(constructor).md) | | Constructs a new instance of the KibanaRequest class | ## Properties | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [body](./kibana-plugin-server.kibanarequest.body.md) | | Body | | -| [headers](./kibana-plugin-server.kibanarequest.headers.md) | | Headers | | | [params](./kibana-plugin-server.kibanarequest.params.md) | | Params | | | [query](./kibana-plugin-server.kibanarequest.query.md) | | Query | | | [route](./kibana-plugin-server.kibanarequest.route.md) | | RecursiveReadonly<KibanaRequestRoute> | | diff --git a/docs/development/core/server/kibana-plugin-server.legacyrequest.md b/docs/development/core/server/kibana-plugin-server.legacyrequest.md new file mode 100644 index 00000000000000..6f67928faa52cf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.legacyrequest.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [LegacyRequest](./kibana-plugin-server.legacyrequest.md) + +## LegacyRequest type + +Support Legacy platform request for the period of migration. + +Signature: + +```typescript +export declare type LegacyRequest = Request; +``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 7b7d3a9f0662e8..079439e6be4723 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -23,12 +23,14 @@ The plugin integrates with the core system via lifecycle events: `setup` | Interface | Description | | --- | --- | +| [AuthResultData](./kibana-plugin-server.authresultdata.md) | Result of an incoming request authentication. | | [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. | | [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. | | [CoreSetup](./kibana-plugin-server.coresetup.md) | Context passed to the plugins setup method. | | [CoreStart](./kibana-plugin-server.corestart.md) | Context passed to the plugins start method. | | [DiscoveredPlugin](./kibana-plugin-server.discoveredplugin.md) | Small container object used to expose information about discovered plugins that may or may not have been started. | | [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | | +| [FakeRequest](./kibana-plugin-server.fakerequest.md) | Fake request object created manually by Kibana plugins. | | [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | | | [HttpServiceStart](./kibana-plugin-server.httpservicestart.md) | | | [InternalCoreStart](./kibana-plugin-server.internalcorestart.md) | | @@ -52,8 +54,11 @@ The plugin integrates with the core system via lifecycle events: `setup` | --- | --- | | [APICaller](./kibana-plugin-server.apicaller.md) | | | [AuthenticationHandler](./kibana-plugin-server.authenticationhandler.md) | | +| [AuthHeaders](./kibana-plugin-server.authheaders.md) | Auth Headers map | | [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | | +| [GetAuthHeaders](./kibana-plugin-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. | | [Headers](./kibana-plugin-server.headers.md) | | +| [LegacyRequest](./kibana-plugin-server.legacyrequest.md) | Support Legacy platform request for the period of migration. | | [OnPostAuthHandler](./kibana-plugin-server.onpostauthhandler.md) | | | [OnPreAuthHandler](./kibana-plugin-server.onpreauthhandler.md) | | | [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.asscoped.md b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.asscoped.md index ed107ae50899b0..fcc5b90e2dd0cc 100644 --- a/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.asscoped.md +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.asscoped.md @@ -7,5 +7,5 @@ Signature: ```typescript -asScoped: (request: Readonly | KibanaRequest) => SessionStorage; +asScoped: (request: KibanaRequest) => SessionStorage; ``` diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.md b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.md index 8f6f58902fde47..eb559005575b1b 100644 --- a/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.md +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.md @@ -16,5 +16,5 @@ export interface SessionStorageFactory | Property | Type | Description | | --- | --- | --- | -| [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md) | (request: Readonly<Request> | KibanaRequest) => SessionStorage<T> | | +| [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md) | (request: KibanaRequest) => SessionStorage<T> | | diff --git a/src/core/server/elasticsearch/cluster_client.test.ts b/src/core/server/elasticsearch/cluster_client.test.ts index de28818072bcf2..db277fa0e06074 100644 --- a/src/core/server/elasticsearch/cluster_client.test.ts +++ b/src/core/server/elasticsearch/cluster_client.test.ts @@ -29,6 +29,7 @@ import { errors } from 'elasticsearch'; import { get } from 'lodash'; import { Logger } from '../logging'; import { loggingServiceMock } from '../logging/logging_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; import { ClusterClient } from './cluster_client'; const logger = loggingServiceMock.create(); @@ -241,7 +242,9 @@ describe('#asScoped', () => { }); test('creates additional Elasticsearch client only once', () => { - const firstScopedClusterClient = clusterClient.asScoped({ headers: { one: '1' } }); + const firstScopedClusterClient = clusterClient.asScoped( + httpServerMock.createRawRequest({ headers: { one: '1' } }) + ); expect(firstScopedClusterClient).toBeDefined(); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); @@ -257,7 +260,9 @@ describe('#asScoped', () => { jest.clearAllMocks(); - const secondScopedClusterClient = clusterClient.asScoped({ headers: { two: '2' } }); + const secondScopedClusterClient = clusterClient.asScoped( + httpServerMock.createRawRequest({ headers: { two: '2' } }) + ); expect(secondScopedClusterClient).toBeDefined(); expect(secondScopedClusterClient).not.toBe(firstScopedClusterClient); @@ -270,7 +275,7 @@ describe('#asScoped', () => { clusterClient = new ClusterClient(mockEsConfig, mockLogger); mockParseElasticsearchClientConfig.mockClear(); - clusterClient.asScoped({ headers: { one: '1' } }); + clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, { @@ -283,7 +288,7 @@ describe('#asScoped', () => { clusterClient = new ClusterClient(mockEsConfig, mockLogger); mockParseElasticsearchClientConfig.mockClear(); - clusterClient.asScoped({ headers: { one: '1' } }); + clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, { @@ -296,7 +301,7 @@ describe('#asScoped', () => { clusterClient = new ClusterClient(mockEsConfig, mockLogger); mockParseElasticsearchClientConfig.mockClear(); - clusterClient.asScoped({ headers: { one: '1' } }); + clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, { @@ -306,7 +311,9 @@ describe('#asScoped', () => { }); test('passes only filtered headers to the scoped cluster client', () => { - clusterClient.asScoped({ headers: { zero: '0', one: '1', two: '2', three: '3' } }); + clusterClient.asScoped( + httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } }) + ); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( @@ -317,7 +324,9 @@ describe('#asScoped', () => { }); test('both scoped and internal API caller fail if cluster client is closed', async () => { - clusterClient.asScoped({ headers: { zero: '0', one: '1', two: '2', three: '3' } }); + clusterClient.asScoped( + httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } }) + ); clusterClient.close(); @@ -330,6 +339,70 @@ describe('#asScoped', () => { `"Cluster client cannot be used after it has been closed."` ); }); + + test('does not fail when scope to not defined request', async () => { + clusterClient = new ClusterClient(mockEsConfig, mockLogger); + clusterClient.asScoped(); + expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); + expect(MockScopedClusterClient).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + {} + ); + }); + + test('does not fail when scope to a request without headers', async () => { + clusterClient = new ClusterClient(mockEsConfig, mockLogger); + clusterClient.asScoped({} as any); + expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); + expect(MockScopedClusterClient).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + {} + ); + }); + + test('calls getAuthHeaders and filters results for a real request', async () => { + clusterClient = new ClusterClient(mockEsConfig, mockLogger, () => ({ one: '1', three: '3' })); + clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { two: '2' } })); + expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); + expect(MockScopedClusterClient).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + { one: '1', two: '2' } + ); + }); + + test('getAuthHeaders results rewrite extends a request headers', async () => { + clusterClient = new ClusterClient(mockEsConfig, mockLogger, () => ({ one: 'foo' })); + clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1', two: '2' } })); + expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); + expect(MockScopedClusterClient).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + { one: 'foo', two: '2' } + ); + }); + + test("doesn't call getAuthHeaders for a fake request", async () => { + const getAuthHeaders = jest.fn(); + clusterClient = new ClusterClient(mockEsConfig, mockLogger, getAuthHeaders); + clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } }); + + expect(getAuthHeaders).not.toHaveBeenCalled(); + }); + + test('filters a fake request headers', async () => { + clusterClient = new ClusterClient(mockEsConfig, mockLogger); + clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } }); + + expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); + expect(MockScopedClusterClient).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + { one: '1', two: '2' } + ); + }); }); describe('#close', () => { @@ -359,7 +432,7 @@ describe('#close', () => { }); test('closes both internal and scoped underlying Elasticsearch clients', () => { - clusterClient.asScoped({ headers: { one: '1' } }); + clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); expect(mockEsClientInstance.close).not.toHaveBeenCalled(); expect(mockScopedEsClientInstance.close).not.toHaveBeenCalled(); @@ -370,7 +443,7 @@ describe('#close', () => { }); test('does not call close on already closed client', () => { - clusterClient.asScoped({ headers: { one: '1' } }); + clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); clusterClient.close(); mockEsClientInstance.close.mockClear(); diff --git a/src/core/server/elasticsearch/cluster_client.ts b/src/core/server/elasticsearch/cluster_client.ts index 45e3f0a20c0c43..73b50f82f75006 100644 --- a/src/core/server/elasticsearch/cluster_client.ts +++ b/src/core/server/elasticsearch/cluster_client.ts @@ -20,7 +20,10 @@ import Boom from 'boom'; import { Client } from 'elasticsearch'; import { get } from 'lodash'; -import { filterHeaders, Headers } from '../http/router'; +import { Request } from 'hapi'; + +import { GetAuthHeaders, isRealRequest } from '../http'; +import { filterHeaders, KibanaRequest, ensureRawRequest } from '../http/router'; import { Logger } from '../logging'; import { ElasticsearchClientConfig, @@ -28,6 +31,15 @@ import { } from './elasticsearch_client_config'; import { ScopedClusterClient } from './scoped_cluster_client'; +/** + * Support Legacy platform request for the period of migration. + * + * @public + */ + +export type LegacyRequest = Request; + +const noop = () => undefined; /** * The set of options that defines how API call should be made and result be * processed. @@ -95,6 +107,15 @@ async function callAPI( } } +/** + * Fake request object created manually by Kibana plugins. + * @public + */ +export interface FakeRequest { + /** Headers used for authentication against Elasticsearch */ + headers: Record; +} + /** * Represents an Elasticsearch cluster API client and allows to call API on behalf * of the internal Kibana user and the actual user that is derived from the request @@ -119,7 +140,11 @@ export class ClusterClient { */ private isClosed = false; - constructor(private readonly config: ElasticsearchClientConfig, private readonly log: Logger) { + constructor( + private readonly config: ElasticsearchClientConfig, + private readonly log: Logger, + private readonly getAuthHeaders: GetAuthHeaders = noop + ) { this.client = new Client(parseElasticsearchClientConfig(config, log)); } @@ -163,9 +188,10 @@ export class ClusterClient { * scoped to the provided req. Consumers shouldn't worry about closing * scoped client instances, these will be automatically closed as soon as the * original cluster client isn't needed anymore and closed. - * @param req - Request the `ScopedClusterClient` instance will be scoped to. + * @param request - Request the `ScopedClusterClient` instance will be scoped to. + * Supports request optionality, Legacy.Request & FakeRequest for BWC with LegacyPlatform */ - public asScoped(req: { headers?: Headers } = {}) { + public asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest) { // It'd have been quite expensive to create and configure client for every incoming // request since it involves parsing of the config, reading of the SSL certificate and // key files etc. Moreover scoped client needs two Elasticsearch JS clients at the same @@ -181,11 +207,11 @@ export class ClusterClient { ); } - const headers = req.headers - ? filterHeaders(req.headers, this.config.requestHeadersWhitelist) - : req.headers; - - return new ScopedClusterClient(this.callAsInternalUser, this.callAsCurrentUser, headers); + return new ScopedClusterClient( + this.callAsInternalUser, + this.callAsCurrentUser, + filterHeaders(this.getHeaders(request), this.config.requestHeadersWhitelist) + ); } /** @@ -210,4 +236,16 @@ export class ClusterClient { throw new Error('Cluster client cannot be used after it has been closed.'); } } + + private getHeaders( + request?: KibanaRequest | LegacyRequest | FakeRequest + ): Record { + if (!isRealRequest(request)) { + return request && request.headers ? request.headers : {}; + } + const authHeaders = this.getAuthHeaders(request); + const headers = ensureRawRequest(request).headers; + + return { ...headers, ...authHeaders }; + } } diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 901ab78130480a..a0f71801293823 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -27,11 +27,15 @@ import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; import { configServiceMock } from '../config/config_service.mock'; import { loggingServiceMock } from '../logging/logging_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; let elasticsearchService: ElasticsearchService; const configService = configServiceMock.create(); +const deps = { + http: httpServiceMock.createSetupContract(), +}; configService.atPath.mockReturnValue( new BehaviorSubject({ hosts: ['http://1.2.3.4'], @@ -54,7 +58,7 @@ afterEach(() => jest.clearAllMocks()); describe('#setup', () => { test('returns legacy Elasticsearch config as a part of the contract', async () => { - const setupContract = await elasticsearchService.setup(); + const setupContract = await elasticsearchService.setup(deps); await expect(setupContract.legacy.config$.pipe(first()).toPromise()).resolves.toBeInstanceOf( ElasticsearchConfig @@ -68,7 +72,7 @@ describe('#setup', () => { () => mockAdminClusterClientInstance ).mockImplementationOnce(() => mockDataClusterClientInstance); - const setupContract = await elasticsearchService.setup(); + const setupContract = await elasticsearchService.setup(deps); const [esConfig, adminClient, dataClient] = await combineLatest( setupContract.legacy.config$, @@ -85,12 +89,14 @@ describe('#setup', () => { expect(MockClusterClient).toHaveBeenNthCalledWith( 1, esConfig, - expect.objectContaining({ context: ['elasticsearch', 'admin'] }) + expect.objectContaining({ context: ['elasticsearch', 'admin'] }), + undefined ); expect(MockClusterClient).toHaveBeenNthCalledWith( 2, esConfig, - expect.objectContaining({ context: ['elasticsearch', 'data'] }) + expect.objectContaining({ context: ['elasticsearch', 'data'] }), + expect.any(Function) ); expect(mockAdminClusterClientInstance.close).not.toHaveBeenCalled(); @@ -98,7 +104,7 @@ describe('#setup', () => { }); test('returns `createClient` as a part of the contract', async () => { - const setupContract = await elasticsearchService.setup(); + const setupContract = await elasticsearchService.setup(deps); const mockClusterClientInstance = { close: jest.fn() }; MockClusterClient.mockImplementation(() => mockClusterClientInstance); @@ -110,7 +116,8 @@ describe('#setup', () => { expect(MockClusterClient).toHaveBeenCalledWith( mockConfig, - expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }) + expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }), + expect.any(Function) ); }); }); @@ -123,7 +130,7 @@ describe('#stop', () => { () => mockAdminClusterClientInstance ).mockImplementationOnce(() => mockDataClusterClientInstance); - await elasticsearchService.setup(); + await elasticsearchService.setup(deps); await elasticsearchService.stop(); expect(mockAdminClusterClientInstance.close).toHaveBeenCalledTimes(1); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index b3faab892bd979..4e90ff2e2baeca 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -25,6 +25,7 @@ import { Logger } from '../logging'; import { ClusterClient } from './cluster_client'; import { ElasticsearchClientConfig } from './elasticsearch_client_config'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; +import { HttpServiceSetup, GetAuthHeaders } from '../http/'; /** @internal */ interface CoreClusterClients { @@ -33,6 +34,10 @@ interface CoreClusterClients { dataClient: ClusterClient; } +interface SetupDeps { + http: HttpServiceSetup; +} + /** @public */ export interface ElasticsearchServiceSetup { // Required for the BWC with the legacy Kibana only. @@ -58,7 +63,7 @@ export class ElasticsearchService implements CoreService new ElasticsearchConfig(rawConfig))); } - public async setup(): Promise { + public async setup(deps: SetupDeps): Promise { this.log.debug('Setting up elasticsearch service'); const clients$ = this.config$.pipe( @@ -78,7 +83,7 @@ export class ElasticsearchService implements CoreService clients.dataClient)), createClient: (type: string, clientConfig: ElasticsearchClientConfig) => { - return this.createClusterClient(type, clientConfig); + return this.createClusterClient(type, clientConfig, deps.http.auth.getAuthHeaders); }, }; } @@ -119,7 +124,15 @@ export class ElasticsearchService implements CoreService { + describe('stores authorization headers', () => { + it('retrieves a copy of headers associated with Kibana request', () => { + const headers = { authorization: 'token' }; + const storage = new AuthHeadersStorage(); + const rawRequest = httpServerMock.createRawRequest(); + storage.set(KibanaRequest.from(rawRequest), headers); + expect(storage.get(KibanaRequest.from(rawRequest))).toEqual(headers); + }); + + it('retrieves a copy of headers associated with Legacy.Request', () => { + const headers = { authorization: 'token' }; + const storage = new AuthHeadersStorage(); + const rawRequest = httpServerMock.createRawRequest(); + storage.set(rawRequest, headers); + expect(storage.get(rawRequest)).toEqual(headers); + }); + + it('retrieves a copy of headers associated with both KibanaRequest & Legacy.Request', () => { + const headers = { authorization: 'token' }; + const storage = new AuthHeadersStorage(); + const rawRequest = httpServerMock.createRawRequest(); + + storage.set(KibanaRequest.from(rawRequest), headers); + expect(storage.get(rawRequest)).toEqual(headers); + }); + }); +}); diff --git a/src/core/server/http/auth_headers_storage.ts b/src/core/server/http/auth_headers_storage.ts new file mode 100644 index 00000000000000..74d59952f5400d --- /dev/null +++ b/src/core/server/http/auth_headers_storage.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Request } from 'hapi'; +import { KibanaRequest, getIncomingMessage } from './router'; +import { AuthHeaders } from './lifecycle/auth'; + +/** + * Get headers to authenticate a user against Elasticsearch. + * @public + * */ +export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; + +export class AuthHeadersStorage { + private authHeadersCache = new WeakMap, AuthHeaders>(); + public set = (request: KibanaRequest | Request, headers: AuthHeaders) => { + this.authHeadersCache.set(getIncomingMessage(request), headers); + }; + public get: GetAuthHeaders = request => { + return this.authHeadersCache.get(getIncomingMessage(request)); + }; +} diff --git a/src/core/server/http/auth_state_storage.ts b/src/core/server/http/auth_state_storage.ts index bd7bf1e62968ca..3d82b43ffdd334 100644 --- a/src/core/server/http/auth_state_storage.ts +++ b/src/core/server/http/auth_state_storage.ts @@ -17,7 +17,7 @@ * under the License. */ import { Request } from 'hapi'; -import { KibanaRequest, toRawRequest } from './router'; +import { KibanaRequest, getIncomingMessage } from './router'; export enum AuthStatus { authenticated = 'authenticated', @@ -25,9 +25,6 @@ export enum AuthStatus { unknown = 'unknown', } -const getIncomingMessage = (request: KibanaRequest | Request) => - request instanceof KibanaRequest ? toRawRequest(request).raw.req : request.raw.req; - export class AuthStateStorage { private readonly storage = new WeakMap, unknown>(); constructor(private readonly canBeAuthenticated: () => boolean) {} diff --git a/src/core/server/http/base_path_service.ts b/src/core/server/http/base_path_service.ts index a6a868547bfb1f..a327c6f0902866 100644 --- a/src/core/server/http/base_path_service.ts +++ b/src/core/server/http/base_path_service.ts @@ -17,13 +17,10 @@ * under the License. */ import { Request } from 'hapi'; -import { KibanaRequest, toRawRequest } from './router'; +import { KibanaRequest, getIncomingMessage } from './router'; import { modifyUrl } from '../../utils'; -const getIncomingMessage = (request: KibanaRequest | Request) => - request instanceof KibanaRequest ? toRawRequest(request).raw.req : request.raw.req; - export class BasePath { private readonly basePathCache = new WeakMap, string>(); diff --git a/src/core/server/http/cookie_session_storage.ts b/src/core/server/http/cookie_session_storage.ts index f0cd50053cf144..559ab9137164f9 100644 --- a/src/core/server/http/cookie_session_storage.ts +++ b/src/core/server/http/cookie_session_storage.ts @@ -20,7 +20,7 @@ import { Request, Server } from 'hapi'; import hapiAuthCookie from 'hapi-auth-cookie'; -import { KibanaRequest, toRawRequest } from './router'; +import { KibanaRequest, ensureRawRequest } from './router'; import { SessionStorageFactory, SessionStorage } from './session_storage'; export interface SessionStorageCookieOptions { @@ -31,7 +31,7 @@ export interface SessionStorageCookieOptions { } class ScopedCookieSessionStorage> implements SessionStorage { - constructor(private readonly server: Server, private readonly request: Readonly) {} + constructor(private readonly server: Server, private readonly request: Request) {} public async get(): Promise { try { return await this.server.auth.test('security-cookie', this.request as Request); @@ -73,9 +73,8 @@ export async function createCookieSessionStorageFactory( }); return { - asScoped(request: Readonly | KibanaRequest) { - const req = request instanceof KibanaRequest ? toRawRequest(request) : request; - return new ScopedCookieSessionStorage(server, req); + asScoped(request: KibanaRequest) { + return new ScopedCookieSessionStorage(server, ensureRawRequest(request)); }, }; } diff --git a/src/core/server/http/cookie_sesson_storage.test.ts b/src/core/server/http/cookie_sesson_storage.test.ts index 91ca1827d25b55..02ce240659a00f 100644 --- a/src/core/server/http/cookie_sesson_storage.test.ts +++ b/src/core/server/http/cookie_sesson_storage.test.ts @@ -16,11 +16,34 @@ * specific language governing permissions and limitations * under the License. */ -import { Server } from 'hapi'; import request from 'request'; +import supertest from 'supertest'; +import { ByteSizeValue } from '@kbn/config-schema'; + +import { HttpServer } from './http_server'; +import { HttpConfig } from './http_config'; +import { Router } from './router'; +import { loggingServiceMock } from '../logging/logging_service.mock'; import { createCookieSessionStorageFactory } from './cookie_session_storage'; +let server: HttpServer; + +const logger = loggingServiceMock.create(); +const config = { + host: '127.0.0.1', + maxPayload: new ByteSizeValue(1024), + ssl: {}, +} as HttpConfig; + +beforeEach(() => { + server = new HttpServer(logger.get()); +}); + +afterEach(async () => { + await server.stop(); +}); + interface User { id: string; roles?: string[]; @@ -53,24 +76,25 @@ const cookieOptions = { describe('Cookie based SessionStorage', () => { describe('#set()', () => { it('Should write to session storage & set cookies', async () => { - const server = new Server(); - const factory = await createCookieSessionStorageFactory(server, cookieOptions); - server.route({ - method: 'GET', - path: '/set', - options: { - handler: (req, h) => { - const sessionStorage = factory.asScoped(req); - sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); - return h.response(); - }, - }, + const router = new Router(''); + + router.get({ path: '/', validate: false }, (req, res) => { + const sessionStorage = factory.asScoped(req); + sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); + return res.ok({}); }); - const response = await server.inject('/set'); - expect(response.statusCode).toBe(200); + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + + const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions); + await server.start(); + + const response = await supertest(innerServer.listener) + .get('/') + .expect(200); - const cookies = response.headers['set-cookie']; + const cookies = response.get('set-cookie'); expect(cookies).toBeDefined(); expect(cookies).toHaveLength(1); @@ -84,100 +108,98 @@ describe('Cookie based SessionStorage', () => { }); describe('#get()', () => { it('Should read from session storage', async () => { - const server = new Server(); - const factory = await createCookieSessionStorageFactory(server, cookieOptions); - server.route({ - method: 'GET', - path: '/get', - options: { - handler: async (req, h) => { - const sessionStorage = factory.asScoped(req); - const sessionValue = await sessionStorage.get(); - if (!sessionValue) { - sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); - return h.response(); - } - return h.response(sessionValue.value); - }, - }, + const router = new Router(''); + + router.get({ path: '/', validate: false }, async (req, res) => { + const sessionStorage = factory.asScoped(req); + const sessionValue = await sessionStorage.get(); + if (!sessionValue) { + sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); + return res.ok({}); + } + return res.ok({ value: sessionValue.value }); }); - const response = await server.inject('/get'); - expect(response.statusCode).toBe(200); + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); - const cookies = response.headers['set-cookie']; + const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions); + await server.start(); + + const response = await supertest(innerServer.listener) + .get('/') + .expect(200); + + const cookies = response.get('set-cookie'); expect(cookies).toBeDefined(); expect(cookies).toHaveLength(1); const sessionCookie = retrieveSessionCookie(cookies[0]); - const response2 = await server.inject({ - method: 'GET', - url: '/get', - headers: { cookie: `${sessionCookie.key}=${sessionCookie.value}` }, - }); - expect(response2.statusCode).toBe(200); - expect(response2.result).toEqual(userData); + await supertest(innerServer.listener) + .get('/') + .set('Cookie', `${sessionCookie.key}=${sessionCookie.value}`) + .expect(200, { value: userData }); }); it('Should return null for empty session', async () => { - const server = new Server(); - const factory = await createCookieSessionStorageFactory(server, cookieOptions); - server.route({ - method: 'GET', - path: '/get-empty', - options: { - handler: async (req, h) => { - const sessionStorage = factory.asScoped(req); - const sessionValue = await sessionStorage.get(); - return h.response(JSON.stringify(sessionValue)); - }, - }, + const router = new Router(''); + + router.get({ path: '/', validate: false }, async (req, res) => { + const sessionStorage = factory.asScoped(req); + const sessionValue = await sessionStorage.get(); + return res.ok({ value: sessionValue }); }); - const response = await server.inject('/get-empty'); - expect(response.statusCode).toBe(200); - expect(response.result).toBe('null'); - const cookies = response.headers['set-cookie']; + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + + const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions); + await server.start(); + + const response = await supertest(innerServer.listener) + .get('/') + .expect(200, { value: null }); + + const cookies = response.get('set-cookie'); expect(cookies).not.toBeDefined(); }); it('Should return null for invalid session & clean cookies', async () => { - const server = new Server(); - const factory = await createCookieSessionStorageFactory(server, cookieOptions); + const router = new Router(''); + let setOnce = false; - server.route({ - method: 'GET', - path: '/get-invalid', - options: { - handler: async (req, h) => { - const sessionStorage = factory.asScoped(req); - if (!setOnce) { - setOnce = true; - sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); - return h.response(); - } - const sessionValue = await sessionStorage.get(); - return h.response(JSON.stringify(sessionValue)); - }, - }, + router.get({ path: '/', validate: false }, async (req, res) => { + const sessionStorage = factory.asScoped(req); + if (!setOnce) { + setOnce = true; + sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); + return res.ok({ value: userData }); + } + const sessionValue = await sessionStorage.get(); + return res.ok({ value: sessionValue }); }); - const response = await server.inject('/get-invalid'); - expect(response.statusCode).toBe(200); - const cookies = response.headers['set-cookie']; + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + + const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions); + await server.start(); + + const response = await supertest(innerServer.listener) + .get('/') + .expect(200, { value: userData }); + + const cookies = response.get('set-cookie'); expect(cookies).toBeDefined(); await delay(sessionDurationMs); const sessionCookie = retrieveSessionCookie(cookies[0]); - const response2 = await server.inject({ - method: 'GET', - url: '/get-invalid', - headers: { cookie: `${sessionCookie.key}=${sessionCookie.value}` }, - }); - expect(response2.statusCode).toBe(200); - expect(response2.result).toBe('null'); + const response2 = await supertest(innerServer.listener) + .get('/') + .set('Cookie', `${sessionCookie.key}=${sessionCookie.value}`) + .expect(200, { value: null }); - const cookies2 = response2.headers['set-cookie']; + const cookies2 = response2.get('set-cookie'); expect(cookies2).toEqual([ 'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/', ]); @@ -185,36 +207,37 @@ describe('Cookie based SessionStorage', () => { }); describe('#clear()', () => { it('Should clear session storage & remove cookies', async () => { - const server = new Server(); - const factory = await createCookieSessionStorageFactory(server, cookieOptions); - server.route({ - method: 'GET', - path: '/clear', - options: { - handler: async (req, h) => { - const sessionStorage = factory.asScoped(req); - if (await sessionStorage.get()) { - sessionStorage.clear(); - return h.response(); - } - sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); - return h.response(); - }, - }, + const router = new Router(''); + + router.get({ path: '/', validate: false }, async (req, res) => { + const sessionStorage = factory.asScoped(req); + if (await sessionStorage.get()) { + sessionStorage.clear(); + return res.ok({}); + } + sessionStorage.set({ value: userData, expires: Date.now() + sessionDurationMs }); + return res.ok({}); }); - const response = await server.inject('/clear'); - const cookies = response.headers['set-cookie']; + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + + const factory = await createCookieSessionStorageFactory(innerServer, cookieOptions); + await server.start(); + + const response = await supertest(innerServer.listener) + .get('/') + .expect(200); + + const cookies = response.get('set-cookie'); const sessionCookie = retrieveSessionCookie(cookies[0]); - const response2 = await server.inject({ - method: 'GET', - url: '/clear', - headers: { cookie: `${sessionCookie.key}=${sessionCookie.value}` }, - }); - expect(response2.statusCode).toBe(200); + const response2 = await supertest(innerServer.listener) + .get('/') + .set('Cookie', `${sessionCookie.key}=${sessionCookie.value}`) + .expect(200); - const cookies2 = response2.headers['set-cookie']; + const cookies2 = response2.get('set-cookie'); expect(cookies2).toEqual([ 'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/', ]); diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 3676b4deaec464..00aba1255f04bd 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -639,7 +639,7 @@ describe('#registerAuth', () => { const user = { id: '42' }; const sessionStorage = sessionStorageFactory.asScoped(req); sessionStorage.set({ value: user, expires: Date.now() + 1000 }); - return t.authenticated(user); + return t.authenticated({ state: user }); }, cookieOptions); registerRouter(router); await server.start(); @@ -715,7 +715,7 @@ describe('#registerAuth', () => { }); }); - it(`allows manipulating cookies from route handler`, async () => { + it('allows manipulating cookies from route handler', async () => { const { registerAuth, registerRouter, server: innerServer } = await server.setup(config); const { sessionStorageFactory } = await registerAuth((req, t) => { const user = { id: '42' }; @@ -749,6 +749,55 @@ describe('#registerAuth', () => { 'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/', ]); }); + + it('is the only place with access to the authorization header', async () => { + const token = 'Basic: user:password'; + const { + registerAuth, + registerOnPreAuth, + registerOnPostAuth, + registerRouter, + server: innerServer, + } = await server.setup(config); + + let fromRegisterOnPreAuth; + await registerOnPreAuth((req, t) => { + fromRegisterOnPreAuth = req.getFilteredHeaders(['authorization']); + return t.next(); + }); + + let fromRegisterAuth; + await registerAuth((req, t) => { + fromRegisterAuth = req.getFilteredHeaders(['authorization']); + return t.authenticated(); + }, cookieOptions); + + let fromRegisterOnPostAuth; + await registerOnPostAuth((req, t) => { + fromRegisterOnPostAuth = req.getFilteredHeaders(['authorization']); + return t.next(); + }); + + let fromRouteHandler; + const router = new Router(''); + router.get({ path: '/', validate: false }, (req, res) => { + fromRouteHandler = req.getFilteredHeaders(['authorization']); + return res.ok({ content: 'ok' }); + }); + registerRouter(router); + + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .set('Authorization', token) + .expect(200); + + expect(fromRegisterOnPreAuth).toEqual({}); + expect(fromRegisterAuth).toEqual({ authorization: token }); + expect(fromRegisterOnPostAuth).toEqual({}); + expect(fromRouteHandler).toEqual({}); + }); }); test('enables auth for a route by default if registerAuth has been called', async () => { @@ -909,7 +958,7 @@ describe('#auth.get()', () => { const { registerRouter, registerAuth, server: innerServer, auth } = await server.setup(config); const { sessionStorageFactory } = await registerAuth((req, t) => { sessionStorageFactory.asScoped(req).set({ value: user, expires: Date.now() + 1000 }); - return t.authenticated(user); + return t.authenticated({ state: user }); }, cookieOptions); const router = new Router(''); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 1cff8cd1d312e2..6fed49ca8e635d 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -32,6 +32,7 @@ import { } from './cookie_session_storage'; import { SessionStorageFactory } from './session_storage'; import { AuthStateStorage } from './auth_state_storage'; +import { AuthHeadersStorage } from './auth_headers_storage'; import { BasePath } from './base_path_service'; export interface HttpServerSetup { @@ -73,6 +74,7 @@ export interface HttpServerSetup { auth: { get: AuthStateStorage['get']; isAuthenticated: AuthStateStorage['isAuthenticated']; + getAuthHeaders: AuthHeadersStorage['get']; }; } @@ -83,9 +85,11 @@ export class HttpServer { private authRegistered = false; private readonly authState: AuthStateStorage; + private readonly authHeaders: AuthHeadersStorage; constructor(private readonly log: Logger) { this.authState = new AuthStateStorage(() => this.authRegistered); + this.authHeaders = new AuthHeadersStorage(); } public isListening() { @@ -120,6 +124,7 @@ export class HttpServer { auth: { get: this.authState.get, isAuthenticated: this.authState.isAuthenticated, + getAuthHeaders: this.authHeaders.get, }, // Return server instance with the connection options so that we can properly // bridge core and the "legacy" Kibana internally. Once this bridge isn't @@ -223,7 +228,10 @@ export class HttpServer { ); this.server.auth.scheme('login', () => ({ - authenticate: adoptToHapiAuthFormat(fn, this.authState.set), + authenticate: adoptToHapiAuthFormat(fn, (req, { state, headers }) => { + this.authState.set(req, state); + this.authHeaders.set(req, headers); + }), })); this.server.auth.strategy('session', 'login'); diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 07040560f89cd5..aa65c3cefa4915 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -43,6 +43,7 @@ const createSetupContractMock = () => { auth: { get: jest.fn(), isAuthenticated: jest.fn(), + getAuthHeaders: jest.fn(), }, createNewServer: jest.fn(), }; diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 056ee53cee89bc..e9c2425bc82cfd 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -19,7 +19,9 @@ export { config, HttpConfig, HttpConfigType } from './http_config'; export { HttpService, HttpServiceSetup, HttpServiceStart } from './http_service'; +export { GetAuthHeaders } from './auth_headers_storage'; export { + isRealRequest, KibanaRequest, KibanaRequestRoute, Router, @@ -28,6 +30,6 @@ export { } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; -export { AuthenticationHandler, AuthToolkit } from './lifecycle/auth'; +export { AuthenticationHandler, AuthHeaders, AuthResultData, AuthToolkit } from './lifecycle/auth'; export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth'; export { SessionStorageFactory, SessionStorage } from './session_storage'; diff --git a/src/core/server/http/integration_tests/http_service.test.mocks.ts b/src/core/server/http/integration_tests/http_service.test.mocks.ts new file mode 100644 index 00000000000000..3982df567ed7ce --- /dev/null +++ b/src/core/server/http/integration_tests/http_service.test.mocks.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const clusterClientMock = jest.fn(); +jest.doMock('../../elasticsearch/scoped_cluster_client', () => ({ + ScopedClusterClient: clusterClientMock, +})); diff --git a/src/core/server/http/integration_tests/http_service.test.ts b/src/core/server/http/integration_tests/http_service.test.ts index 21207d0b90e25a..eff012fc5075d2 100644 --- a/src/core/server/http/integration_tests/http_service.test.ts +++ b/src/core/server/http/integration_tests/http_service.test.ts @@ -17,6 +17,9 @@ * under the License. */ import Boom from 'boom'; +import { Request } from 'hapi'; +import { first } from 'rxjs/operators'; +import { clusterClientMock } from './http_service.test.mocks'; import { Router } from '../router'; import * as kbnTestServer from '../../../../test_utils/kbn_server'; @@ -48,16 +51,20 @@ describe('http service', () => { root = kbnTestServer.createRoot(); }, 30000); - afterEach(async () => await root.shutdown()); + afterEach(async () => { + clusterClientMock.mockClear(); + await root.shutdown(); + }); - it('Should run auth for legacy routes and proxy request to legacy server route handlers', async () => { + it('runs auth for legacy routes and proxy request to legacy server route handlers', async () => { const { http } = await root.setup(); const { sessionStorageFactory } = await http.registerAuth((req, t) => { - if (req.headers.authorization) { + const headers = req.getFilteredHeaders(['authorization']); + if (headers.authorization) { const user = { id: '42' }; const sessionStorage = sessionStorageFactory.asScoped(req); sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs }); - return t.authenticated(user); + return t.authenticated({ state: user }); } else { return t.rejected(Boom.unauthorized()); } @@ -79,15 +86,54 @@ describe('http service', () => { expect(response.header['set-cookie']).toBe(undefined); }); - it('Should pass associated auth state to Legacy platform', async () => { + it('passes authHeaders as request headers to the legacy platform', async () => { + const token = 'Basic: name:password'; + const { http } = await root.setup(); + const { sessionStorageFactory } = await http.registerAuth((req, t) => { + const headers = req.getFilteredHeaders(['authorization']); + if (headers.authorization) { + const user = { id: '42' }; + const sessionStorage = sessionStorageFactory.asScoped(req); + sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs }); + return t.authenticated({ + state: user, + headers: { + authorization: token, + }, + }); + } else { + return t.rejected(Boom.unauthorized()); + } + }, cookieOptions); + await root.start(); + + const legacyUrl = '/legacy'; + const kbnServer = kbnTestServer.getKbnServer(root); + kbnServer.server.route({ + method: 'GET', + path: legacyUrl, + handler: (req: Request) => ({ + authorization: req.headers.authorization, + custom: req.headers.custom, + }), + }); + + await kbnTestServer.request + .get(root, legacyUrl) + .set({ custom: 'custom-header' }) + .expect(200, { authorization: token, custom: 'custom-header' }); + }); + + it('passes associated auth state to Legacy platform', async () => { const user = { id: '42' }; const { http } = await root.setup(); const { sessionStorageFactory } = await http.registerAuth((req, t) => { - if (req.headers.authorization) { + const headers = req.getFilteredHeaders(['authorization']); + if (headers.authorization) { const sessionStorage = sessionStorageFactory.asScoped(req); sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs }); - return t.authenticated(user); + return t.authenticated({ state: user }); } else { return t.rejected(Boom.unauthorized()); } @@ -108,6 +154,60 @@ describe('http service', () => { expect(response.header['set-cookie']).toBe(undefined); }); + + it('rewrites authorization header via authHeaders to make a request to Elasticsearch', async () => { + const authHeaders = { authorization: 'Basic: user:password' }; + const { http, elasticsearch } = await root.setup(); + const { registerAuth, registerRouter } = http; + + await registerAuth((req, t) => { + return t.authenticated({ headers: authHeaders }); + }, cookieOptions); + + const router = new Router(''); + router.get({ path: '/', validate: false }, async (req, res) => { + const client = await elasticsearch.dataClient$.pipe(first()).toPromise(); + client.asScoped(req); + return res.ok({ header: 'ok' }); + }); + registerRouter(router); + + await root.start(); + + await kbnTestServer.request.get(root, '/').expect(200); + expect(clusterClientMock).toBeCalledTimes(1); + const [firstCall] = clusterClientMock.mock.calls; + const [, , headers] = firstCall; + expect(headers).toEqual(authHeaders); + }); + + it('pass request authorization header to Elasticsearch if registerAuth was not set', async () => { + const authorizationHeader = 'Basic: username:password'; + const { http, elasticsearch } = await root.setup(); + const { registerRouter } = http; + + const router = new Router(''); + router.get({ path: '/', validate: false }, async (req, res) => { + const client = await elasticsearch.dataClient$.pipe(first()).toPromise(); + client.asScoped(req); + return res.ok({ header: 'ok' }); + }); + registerRouter(router); + + await root.start(); + + await kbnTestServer.request + .get(root, '/') + .set('Authorization', authorizationHeader) + .expect(200); + + expect(clusterClientMock).toBeCalledTimes(1); + const [firstCall] = clusterClientMock.mock.calls; + const [, , headers] = firstCall; + expect(headers).toEqual({ + authorization: authorizationHeader, + }); + }); }); describe('#registerOnPostAuth()', () => { @@ -117,7 +217,7 @@ describe('http service', () => { }, 30000); afterEach(async () => await root.shutdown()); - it('Should support passing request through to the route handler', async () => { + it('supports passing request through to the route handler', async () => { const router = new Router(''); router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' })); @@ -133,7 +233,7 @@ describe('http service', () => { await kbnTestServer.request.get(root, '/').expect(200, { content: 'ok' }); }); - it('Should support redirecting to configured url', async () => { + it('supports redirecting to configured url', async () => { const redirectTo = '/redirect-url'; const { http } = await root.setup(); http.registerOnPostAuth(async (req, t) => t.redirected(redirectTo)); @@ -143,7 +243,7 @@ describe('http service', () => { expect(response.header.location).toBe(redirectTo); }); - it('Should failing a request with configured error and status code', async () => { + it('fails a request with configured error and status code', async () => { const { http } = await root.setup(); http.registerOnPostAuth(async (req, t) => t.rejected(new Error('unexpected error'), { statusCode: 400 }) @@ -155,7 +255,7 @@ describe('http service', () => { .expect(400, { statusCode: 400, error: 'Bad Request', message: 'unexpected error' }); }); - it(`Shouldn't expose internal error details`, async () => { + it(`doesn't expose internal error details`, async () => { const { http } = await root.setup(); http.registerOnPostAuth(async (req, t) => { throw new Error('sensitive info'); @@ -169,7 +269,7 @@ describe('http service', () => { }); }); - it(`Shouldn't share request object between interceptors`, async () => { + it(`doesn't share request object between interceptors`, async () => { const { http } = await root.setup(); http.registerOnPostAuth(async (req, t) => { // @ts-ignore. don't complain customField is not defined on Request type diff --git a/src/core/server/http/lifecycle/auth.test.ts b/src/core/server/http/lifecycle/auth.test.ts index 031556c70483ce..668d2a4fd11dcf 100644 --- a/src/core/server/http/lifecycle/auth.test.ts +++ b/src/core/server/http/lifecycle/auth.test.ts @@ -22,10 +22,14 @@ import { adoptToHapiAuthFormat } from './auth'; import { httpServerMock } from '../http_server.mocks'; describe('adoptToHapiAuthFormat', () => { - it('Should allow authenticating a user identity with given credentials', async () => { - const credentials = {}; + it('allows to associate arbitrary data with an incoming request', async () => { + const authData = { + state: { foo: 'bar' }, + headers: { authorization: 'baz' }, + }; const authenticatedMock = jest.fn(); - const onAuth = adoptToHapiAuthFormat((req, t) => t.authenticated(credentials)); + const onSuccessMock = jest.fn(); + const onAuth = adoptToHapiAuthFormat((req, t) => t.authenticated(authData), onSuccessMock); await onAuth( httpServerMock.createRawRequest(), httpServerMock.createRawResponseToolkit({ @@ -34,12 +38,17 @@ describe('adoptToHapiAuthFormat', () => { ); expect(authenticatedMock).toBeCalledTimes(1); - expect(authenticatedMock).toBeCalledWith({ credentials }); + expect(authenticatedMock).toBeCalledWith({ credentials: authData.state }); + + expect(onSuccessMock).toBeCalledTimes(1); + const [[, onSuccessData]] = onSuccessMock.mock.calls; + expect(onSuccessData).toEqual(authData); }); it('Should allow redirecting to specified url', async () => { const redirectUrl = '/docs'; - const onAuth = adoptToHapiAuthFormat((req, t) => t.redirected(redirectUrl)); + const onSuccessMock = jest.fn(); + const onAuth = adoptToHapiAuthFormat((req, t) => t.redirected(redirectUrl), onSuccessMock); const takeoverSymbol = {}; const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol })); const result = await onAuth( @@ -51,11 +60,14 @@ describe('adoptToHapiAuthFormat', () => { expect(redirectMock).toBeCalledWith(redirectUrl); expect(result).toBe(takeoverSymbol); + expect(onSuccessMock).not.toHaveBeenCalled(); }); it('Should allow to specify statusCode and message for Boom error', async () => { - const onAuth = adoptToHapiAuthFormat((req, t) => - t.rejected(new Error('not found'), { statusCode: 404 }) + const onSuccessMock = jest.fn(); + const onAuth = adoptToHapiAuthFormat( + (req, t) => t.rejected(new Error('not found'), { statusCode: 404 }), + onSuccessMock ); const result = (await onAuth( httpServerMock.createRawRequest(), @@ -65,6 +77,7 @@ describe('adoptToHapiAuthFormat', () => { expect(result).toBeInstanceOf(Boom); expect(result.message).toBe('not found'); expect(result.output.statusCode).toBe(404); + expect(onSuccessMock).not.toHaveBeenCalled(); }); it('Should return Boom.internal error error if interceptor throws', async () => { diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts index bcb7e454b41195..b866c00a756cc1 100644 --- a/src/core/server/http/lifecycle/auth.ts +++ b/src/core/server/http/lifecycle/auth.ts @@ -19,6 +19,7 @@ import Boom from 'boom'; import { noop } from 'lodash'; import { Lifecycle, Request, ResponseToolkit } from 'hapi'; +import { KibanaRequest } from '../router'; enum ResultType { authenticated = 'authenticated', @@ -26,9 +27,8 @@ enum ResultType { rejected = 'rejected', } -interface Authenticated { +interface Authenticated extends AuthResultData { type: ResultType.authenticated; - state: object; } interface Redirected { @@ -45,8 +45,12 @@ interface Rejected { type AuthResult = Authenticated | Rejected | Redirected; const authResult = { - authenticated(state: object = {}): AuthResult { - return { type: ResultType.authenticated, state }; + authenticated(data: Partial = {}): AuthResult { + return { + type: ResultType.authenticated, + state: data.state || {}, + headers: data.headers || {}, + }; }, redirected(url: string): AuthResult { return { type: ResultType.redirected, url }; @@ -73,13 +77,35 @@ const authResult = { }, }; +/** + * Auth Headers map + * @public + * */ + +export type AuthHeaders = Record; + +/** + * Result of an incoming request authentication. + * @public + * */ +export interface AuthResultData { + /** + * Data to associate with an incoming request. Any downstream plugin may get access to the data. + */ + state: Record; + /** + * Auth specific headers to authenticate a user against Elasticsearch. + */ + headers: AuthHeaders; +} + /** * @public * A tool set defining an outcome of Auth interceptor for incoming request. */ export interface AuthToolkit { /** Authentication is successful with given credentials, allow request to pass through */ - authenticated: (state?: object) => AuthResult; + authenticated: (data?: Partial) => AuthResult; /** Authentication requires to interrupt request handling and redirect to a configured url */ redirected: (url: string) => AuthResult; /** Authentication is unsuccessful, fail the request with specified error. */ @@ -94,29 +120,29 @@ const toolkit: AuthToolkit = { /** @public */ export type AuthenticationHandler = ( - request: Readonly, + request: KibanaRequest, t: AuthToolkit ) => AuthResult | Promise; /** @public */ export function adoptToHapiAuthFormat( fn: AuthenticationHandler, - onSuccess: (req: Request, state: unknown) => void = noop + onSuccess: (req: Request, data: AuthResultData) => void = noop ) { return async function interceptAuth( req: Request, h: ResponseToolkit ): Promise { try { - const result = await fn(req, toolkit); + const result = await fn(KibanaRequest.from(req, undefined, false), toolkit); if (!authResult.isValid(result)) { throw new Error( `Unexpected result from Authenticate. Expected AuthResult, but given: ${result}.` ); } if (authResult.isAuthenticated(result)) { - onSuccess(req, result.state); - return h.authenticated({ credentials: result.state }); + onSuccess(req, { state: result.state, headers: result.headers }); + return h.authenticated({ credentials: result.state || {} }); } if (authResult.isRedirected(result)) { return h.redirect(result.url).takeover(); diff --git a/src/core/server/http/router/headers.ts b/src/core/server/http/router/headers.ts index d578542d7a9ceb..956ef8cd2ebb8a 100644 --- a/src/core/server/http/router/headers.ts +++ b/src/core/server/http/router/headers.ts @@ -24,9 +24,16 @@ export type Headers = Record; const normalizeHeaderField = (field: string) => field.trim().toLowerCase(); -export function filterHeaders(headers: Headers, fieldsToKeep: string[]) { +export function filterHeaders( + headers: Headers, + fieldsToKeep: string[], + fieldsToExclude: string[] = [] +) { + const fieldsToExcludeNormalized = fieldsToExclude.map(normalizeHeaderField); // Normalize list of headers we want to allow in upstream request - const fieldsToKeepNormalized = fieldsToKeep.map(normalizeHeaderField); + const fieldsToKeepNormalized = fieldsToKeep + .map(normalizeHeaderField) + .filter(name => !fieldsToExcludeNormalized.includes(name)); return pick(headers, fieldsToKeepNormalized); } diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index cb941326e23f1b..ad088756ddd2ec 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -19,5 +19,11 @@ export { Headers, filterHeaders } from './headers'; export { Router } from './router'; -export { KibanaRequest, KibanaRequestRoute, toRawRequest } from './request'; +export { + KibanaRequest, + KibanaRequestRoute, + ensureRawRequest, + isRealRequest, + getIncomingMessage, +} from './request'; export { RouteMethod, RouteConfigOptions } from './route'; diff --git a/src/core/server/http/router/request.test.ts b/src/core/server/http/router/request.test.ts new file mode 100644 index 00000000000000..9861769e14a311 --- /dev/null +++ b/src/core/server/http/router/request.test.ts @@ -0,0 +1,73 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { KibanaRequest } from './request'; +import { httpServerMock } from '../http_server.mocks'; + +describe('KibanaRequest', () => { + describe('#getFilteredHeaders', () => { + it('returns request headers', () => { + const request = httpServerMock.createRawRequest({ + headers: { custom: 'one' }, + }); + const kibanaRequest = KibanaRequest.from(request); + expect(kibanaRequest.getFilteredHeaders(['custom'])).toEqual({ + custom: 'one', + }); + }); + + it('normalizes a header name', () => { + const request = httpServerMock.createRawRequest({ + headers: { custom: 'one' }, + }); + const kibanaRequest = KibanaRequest.from(request); + expect(kibanaRequest.getFilteredHeaders(['CUSTOM'])).toEqual({ + custom: 'one', + }); + }); + + it('returns an empty object is no headers were specified', () => { + const request = httpServerMock.createRawRequest({ + headers: { custom: 'one' }, + }); + const kibanaRequest = KibanaRequest.from(request); + expect(kibanaRequest.getFilteredHeaders([])).toEqual({}); + }); + + it("doesn't expose authorization header by default", () => { + const request = httpServerMock.createRawRequest({ + headers: { custom: 'one', authorization: 'token' }, + }); + const kibanaRequest = KibanaRequest.from(request); + expect(kibanaRequest.getFilteredHeaders(['custom', 'authorization'])).toEqual({ + custom: 'one', + }); + }); + + it('exposes authorization header if secured = false', () => { + const request = httpServerMock.createRawRequest({ + headers: { custom: 'one', authorization: 'token' }, + }); + const kibanaRequest = KibanaRequest.from(request, undefined, false); + expect(kibanaRequest.getFilteredHeaders(['custom', 'authorization'])).toEqual({ + custom: 'one', + authorization: 'token', + }); + }); + }); +}); diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 3c235ffbf8bd9e..87313de8fc37c6 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -18,11 +18,12 @@ */ import { Url } from 'url'; +import { IncomingMessage } from 'http'; import { ObjectType, TypeOf } from '@kbn/config-schema'; import { Request } from 'hapi'; import { deepFreeze, RecursiveReadonly } from '../../../utils'; -import { filterHeaders, Headers } from './headers'; +import { filterHeaders } from './headers'; import { RouteMethod, RouteSchemas, RouteConfigOptions } from './route'; const requestSymbol = Symbol('request'); @@ -37,6 +38,7 @@ export interface KibanaRequestRoute { options: Required; } +const secretHeaders = ['authorization']; /** * Kibana specific abstraction for an incoming request. * @public @@ -49,10 +51,17 @@ export class KibanaRequest { */ public static from

( req: Request, - routeSchemas?: RouteSchemas + routeSchemas?: RouteSchemas, + withoutSecretHeaders: boolean = true ) { const requestParts = KibanaRequest.validate(req, routeSchemas); - return new KibanaRequest(req, requestParts.params, requestParts.query, requestParts.body); + return new KibanaRequest( + req, + requestParts.params, + requestParts.query, + requestParts.body, + withoutSecretHeaders + ); } /** @@ -86,7 +95,6 @@ export class KibanaRequest { return { query, params, body }; } - public readonly headers: Headers; public readonly url: Url; public readonly route: RecursiveReadonly; @@ -97,17 +105,26 @@ export class KibanaRequest { request: Request, readonly params: Params, readonly query: Query, - readonly body: Body + readonly body: Body, + private readonly withoutSecretHeaders: boolean ) { - this.headers = request.headers; this.url = request.url; - this[requestSymbol] = request; + // prevent Symbol exposure via Object.getOwnPropertySymbols() + Object.defineProperty(this, requestSymbol, { + value: request, + enumerable: false, + }); + this.route = deepFreeze(this.getRouteInfo()); } public getFilteredHeaders(headersToKeep: string[]) { - return filterHeaders(this.headers, headersToKeep); + return filterHeaders( + this[requestSymbol].headers, + headersToKeep, + this.withoutSecretHeaders ? secretHeaders : [] + ); } private getRouteInfo() { @@ -124,7 +141,38 @@ export class KibanaRequest { } /** - * Returns underlying Hapi Request object for KibanaRequest + * Returns underlying Hapi Request + * @internal + */ +export const ensureRawRequest = (request: KibanaRequest | Request) => + isKibanaRequest(request) ? request[requestSymbol] : request; + +/** + * Returns http.IncomingMessage that is used an identifier for New Platform KibanaRequest + * and Legacy platform Hapi Request. + * Exposed while New platform supports Legacy Platform. * @internal */ -export const toRawRequest = (request: KibanaRequest) => request[requestSymbol]; +export const getIncomingMessage = (request: KibanaRequest | Request): IncomingMessage => { + return ensureRawRequest(request).raw.req; +}; + +function isKibanaRequest(request: unknown): request is KibanaRequest { + return request instanceof KibanaRequest; +} + +function isRequest(request: any): request is Request { + try { + return request.raw.req && typeof request.raw.req === 'object'; + } catch { + return false; + } +} + +/** + * Checks if an incoming request either KibanaRequest or Legacy.Request + * @internal + */ +export function isRealRequest(request: unknown): request is KibanaRequest | Request { + return isKibanaRequest(request) || isRequest(request); +} diff --git a/src/core/server/http/session_storage.ts b/src/core/server/http/session_storage.ts index 2c726ce34a3cbb..3e2b51f1848b10 100644 --- a/src/core/server/http/session_storage.ts +++ b/src/core/server/http/session_storage.ts @@ -17,7 +17,6 @@ * under the License. */ -import { Request } from 'hapi'; import { KibanaRequest } from './router'; /** * Provides an interface to store and retrieve data across requests. @@ -43,5 +42,5 @@ export interface SessionStorage { * SessionStorage factory to bind one to an incoming request * @public */ export interface SessionStorageFactory { - asScoped: (request: Readonly | KibanaRequest) => SessionStorage; + asScoped: (request: KibanaRequest) => SessionStorage; } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f901b25e1ae874..ae9a793257ea88 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -49,10 +49,15 @@ export { ScopedClusterClient, ElasticsearchClientConfig, APICaller, + FakeRequest, + LegacyRequest, } from './elasticsearch'; export { AuthenticationHandler, + AuthHeaders, + AuthResultData, AuthToolkit, + GetAuthHeaders, KibanaRequest, KibanaRequestRoute, OnPreAuthHandler, diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 759a2eb76fd0c4..386532ba6565b8 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -75,6 +75,9 @@ beforeEach(() => { http: { options: { someOption: 'foo', someAnotherOption: 'bar' }, server: { listener: { addListener: jest.fn() }, route: jest.fn() }, + auth: { + getAuthHeaders: () => undefined, + }, }, plugins: { contracts: new Map([['plugin-id', 'plugin-value']]), diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index fd1b46d7fa7118..22e092f95df8c0 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -190,6 +190,11 @@ export class LegacyService implements CoreService { } private setupProxyListener(server: HapiServer) { + const { setupDeps } = this; + if (!setupDeps) { + throw new Error('Legacy service is not setup yet.'); + } + const legacyProxy = new LegacyPlatformProxy( this.coreContext.logger.get('legacy-proxy'), server.listener @@ -211,7 +216,13 @@ export class LegacyService implements CoreService { maxBytes: Number.MAX_SAFE_INTEGER, }, }, - handler: async ({ raw: { req, res } }, responseToolkit) => { + handler: async (request, responseToolkit) => { + const { req, res } = request.raw; + const authHeaders = setupDeps.core.http.auth.getAuthHeaders(request); + if (authHeaders) { + // some plugins in Legacy relay on headers.authorization presence + req.headers = Object.assign(req.headers, authHeaders); + } if (this.kbnServer === undefined) { this.log.debug(`Kibana server is not ready yet ${req.method}:${req.url}.`); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index c0e1ce90287065..356f4443b5a76c 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -25,11 +25,20 @@ export type APICaller = (endpoint: string, clientParams: Record // Warning: (ae-forgotten-export) The symbol "AuthResult" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type AuthenticationHandler = (request: Readonly, t: AuthToolkit) => AuthResult | Promise; +export type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise; + +// @public +export type AuthHeaders = Record; + +// @public +export interface AuthResultData { + headers: AuthHeaders; + state: Record; +} // @public export interface AuthToolkit { - authenticated: (state?: object) => AuthResult; + authenticated: (data?: Partial) => AuthResult; redirected: (url: string) => AuthResult; rejected: (error: Error, options?: { statusCode?: number; @@ -49,10 +58,8 @@ export interface CallAPIOptions { // @public export class ClusterClient { - constructor(config: ElasticsearchClientConfig, log: Logger); - asScoped(req?: { - headers?: Headers; - }): ScopedClusterClient; + constructor(config: ElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); + asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): ScopedClusterClient; callAsInternalUser: (endpoint: string, clientParams?: Record, options?: CallAPIOptions | undefined) => Promise; close(): void; } @@ -128,6 +135,14 @@ export interface ElasticsearchServiceSetup { }; } +// @public +export interface FakeRequest { + headers: Record; +} + +// @public +export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; + // @public (undocumented) export type Headers = Record; @@ -168,18 +183,16 @@ export interface InternalCoreStart { export class KibanaRequest { // @internal (undocumented) protected readonly [requestSymbol]: Request; - constructor(request: Request, params: Params, query: Query, body: Body); + constructor(request: Request, params: Params, query: Query, body: Body, withoutSecretHeaders: boolean); // (undocumented) readonly body: Body; // Warning: (ae-forgotten-export) The symbol "RouteSchemas" needs to be exported by the entry point index.d.ts // // @internal - static from

(req: Request, routeSchemas?: RouteSchemas): KibanaRequest; + static from

(req: Request, routeSchemas?: RouteSchemas, withoutSecretHeaders?: boolean): KibanaRequest; // (undocumented) getFilteredHeaders(headersToKeep: string[]): Pick, string>; // (undocumented) - readonly headers: Headers; - // (undocumented) readonly params: Params; // (undocumented) readonly query: Query; @@ -199,6 +212,9 @@ export interface KibanaRequestRoute { path: string; } +// @public +export type LegacyRequest = Request; + // @public export interface Logger { debug(message: string, meta?: LogMeta): void; @@ -397,7 +413,7 @@ export interface SessionStorage { // @public export interface SessionStorageFactory { // (undocumented) - asScoped: (request: Readonly | KibanaRequest) => SessionStorage; + asScoped: (request: KibanaRequest) => SessionStorage; } diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 4f56e20f2c021b..fdda808cd3c5a9 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -61,7 +61,9 @@ export class Server { const httpSetup = await this.http.setup(); this.registerDefaultRoute(httpSetup); - const elasticsearchServiceSetup = await this.elasticsearch.setup(); + const elasticsearchServiceSetup = await this.elasticsearch.setup({ + http: httpSetup, + }); const pluginsSetup = await this.plugins.setup({ elasticsearch: elasticsearchServiceSetup, diff --git a/src/core/utils/map_to_object.ts b/src/core/utils/map_to_object.ts index 14767f566674ff..bfbe5c8ab0beaf 100644 --- a/src/core/utils/map_to_object.ts +++ b/src/core/utils/map_to_object.ts @@ -17,8 +17,8 @@ * under the License. */ -export function mapToObject(map: Map) { - const result: Record = Object.create(null); +export function mapToObject(map: Map) { + const result: Record = Object.create(null); for (const [key, value] of map) { result[key] = value; } diff --git a/src/legacy/core_plugins/elasticsearch/lib/cluster.ts b/src/legacy/core_plugins/elasticsearch/lib/cluster.ts index 873b9c8f8b59c8..a595fffb3c2350 100644 --- a/src/legacy/core_plugins/elasticsearch/lib/cluster.ts +++ b/src/legacy/core_plugins/elasticsearch/lib/cluster.ts @@ -17,8 +17,9 @@ * under the License. */ +import { Request } from 'hapi'; import { errors } from 'elasticsearch'; -import { CallAPIOptions, ClusterClient } from 'kibana/server'; +import { CallAPIOptions, ClusterClient, FakeRequest } from 'kibana/server'; export class Cluster { public readonly errors = errors; @@ -26,7 +27,7 @@ export class Cluster { constructor(private readonly clusterClient: ClusterClient) {} public callWithRequest = async ( - req: { headers?: Record } = {}, + req: Request | FakeRequest, endpoint: string, clientParams?: Record, options?: CallAPIOptions