diff --git a/docs/development/core/server/kibana-plugin-server.authenticationhandler.md b/docs/development/core/server/kibana-plugin-server.authenticationhandler.md new file mode 100644 index 0000000000000..3f087489c1376 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authenticationhandler.md @@ -0,0 +1,10 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthenticationHandler](./kibana-plugin-server.authenticationhandler.md) + +## AuthenticationHandler type + + +Signature: + +```typescript +export declare type AuthenticationHandler = (request: Request, sessionStorage: SessionStorage, t: AuthToolkit) => Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md new file mode 100644 index 0000000000000..cec5cf405924c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md @@ -0,0 +1,11 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) + +## AuthToolkit.authenticated property + +Authentication is successful with given credentials, allow request to pass through + +Signature: + +```typescript +authenticated: (credentials: any) => AuthResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.md new file mode 100644 index 0000000000000..60590ee448e57 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.md @@ -0,0 +1,20 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) + +## AuthToolkit interface + +A tool set defining an outcome of Auth interceptor for incoming request. + +Signature: + +```typescript +export interface AuthToolkit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (credentials: any) => 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.authtoolkit.redirected.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.redirected.md new file mode 100644 index 0000000000000..1ebe9a8549ff3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.redirected.md @@ -0,0 +1,11 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [redirected](./kibana-plugin-server.authtoolkit.redirected.md) + +## AuthToolkit.redirected property + +Authentication requires to interrupt request handling and redirect to a configured url + +Signature: + +```typescript +redirected: (url: string) => AuthResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.rejected.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.rejected.md new file mode 100644 index 0000000000000..dffa66531c37d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.rejected.md @@ -0,0 +1,13 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [rejected](./kibana-plugin-server.authtoolkit.rejected.md) + +## AuthToolkit.rejected property + +Authentication is unsuccessful, fail the request with specified error. + +Signature: + +```typescript +rejected: (error: Error, options?: { + statusCode?: number; + }) => AuthResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-server.httpservicesetup.md new file mode 100644 index 0000000000000..a5517f26bdf96 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpservicesetup.md @@ -0,0 +1,10 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) + +## HttpServiceSetup type + + +Signature: + +```typescript +export declare type HttpServiceSetup = HttpServerInfo; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.body.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.body.md new file mode 100644 index 0000000000000..e08f84ea44bba --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.body.md @@ -0,0 +1,9 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [body](./kibana-plugin-server.kibanarequest.body.md) + +## KibanaRequest.body property + +Signature: + +```typescript +readonly body: Body; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.from.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.from.md new file mode 100644 index 0000000000000..7d762642bd99f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.from.md @@ -0,0 +1,23 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [from](./kibana-plugin-server.kibanarequest.from.md) + +## KibanaRequest.from() method + +Factory for creating requests. Validates the request before creating an instance of a KibanaRequest. + +Signature: + +```typescript +static from

(req: Request, routeSchemas: RouteSchemas | undefined): KibanaRequest; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| req | Request | | +| routeSchemas | RouteSchemas<P, Q, B> | undefined | | + +Returns: + +`KibanaRequest` + diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.getfilteredheaders.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.getfilteredheaders.md new file mode 100644 index 0000000000000..defa9b739586f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.getfilteredheaders.md @@ -0,0 +1,20 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [getFilteredHeaders](./kibana-plugin-server.kibanarequest.getfilteredheaders.md) + +## KibanaRequest.getFilteredHeaders() method + +Signature: + +```typescript +getFilteredHeaders(headersToKeep: string[]): Pick, string>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| headersToKeep | string[] | | + +Returns: + +`Pick, string>` + diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.headers.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.headers.md new file mode 100644 index 0000000000000..f920942db4a67 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.headers.md @@ -0,0 +1,9 @@ +[Home](./index) > [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 new file mode 100644 index 0000000000000..835e34c9e602a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.md @@ -0,0 +1,28 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) + +## KibanaRequest class + + +Signature: + +```typescript +export declare class KibanaRequest +``` + +## 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 | | +| [path](./kibana-plugin-server.kibanarequest.path.md) | | string | | +| [query](./kibana-plugin-server.kibanarequest.query.md) | | Query | | + +## Methods + +| Method | Modifiers | Description | +| --- | --- | --- | +| [from(req, routeSchemas)](./kibana-plugin-server.kibanarequest.from.md) | static | Factory for creating requests. Validates the request before creating an instance of a KibanaRequest. | +| [getFilteredHeaders(headersToKeep)](./kibana-plugin-server.kibanarequest.getfilteredheaders.md) | | | + diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.params.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.params.md new file mode 100644 index 0000000000000..c8b57329aac0d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.params.md @@ -0,0 +1,9 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [params](./kibana-plugin-server.kibanarequest.params.md) + +## KibanaRequest.params property + +Signature: + +```typescript +readonly params: Params; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.path.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.path.md new file mode 100644 index 0000000000000..269fcfd4e4937 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.path.md @@ -0,0 +1,9 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [path](./kibana-plugin-server.kibanarequest.path.md) + +## KibanaRequest.path property + +Signature: + +```typescript +readonly path: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.query.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.query.md new file mode 100644 index 0000000000000..17cf96f3c3a93 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.query.md @@ -0,0 +1,9 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [query](./kibana-plugin-server.kibanarequest.query.md) + +## KibanaRequest.query property + +Signature: + +```typescript +readonly query: Query; +``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 1071b039c0806..9c1d0b06fa70e 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -8,18 +8,22 @@ | --- | --- | | [ClusterClient](./kibana-plugin-server.clusterclient.md) | 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 headers (via asScoped(...)). | | [ConfigService](./kibana-plugin-server.configservice.md) | | +| [KibanaRequest](./kibana-plugin-server.kibanarequest.md) | | +| [Router](./kibana-plugin-server.router.md) | | | [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md) | Serves the same purpose as "normal" ClusterClient but exposes additional callAsCurrentUser method that doesn't use credentials of the Kibana internal user (as callAsInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API | ## Interfaces | Interface | Description | | --- | --- | +| [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) | | | [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | | | [Logger](./kibana-plugin-server.logger.md) | Logger exposes all the necessary methods to log any type of information and this is the interface used by the logging consumers including plugins. | | [LoggerFactory](./kibana-plugin-server.loggerfactory.md) | The single purpose of LoggerFactory interface is to define a way to retrieve a context-based logger instance. | | [LogMeta](./kibana-plugin-server.logmeta.md) | Contextual metadata | +| [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) | A tool set defining an outcome of OnRequest interceptor for incoming request. | | [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a PluginInitializer. | | [PluginInitializerContext](./kibana-plugin-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginSetupContext](./kibana-plugin-server.pluginsetupcontext.md) | Context passed to the plugins setup method. | @@ -29,8 +33,11 @@ | Type Alias | Description | | --- | --- | | [APICaller](./kibana-plugin-server.apicaller.md) | | +| [AuthenticationHandler](./kibana-plugin-server.authenticationhandler.md) | | | [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | | | [Headers](./kibana-plugin-server.headers.md) | | +| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | | +| [OnRequestHandler](./kibana-plugin-server.onrequesthandler.md) | | | [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | | [PluginName](./kibana-plugin-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | diff --git a/docs/development/core/server/kibana-plugin-server.onrequesthandler.md b/docs/development/core/server/kibana-plugin-server.onrequesthandler.md new file mode 100644 index 0000000000000..5f093fef4eb20 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.onrequesthandler.md @@ -0,0 +1,10 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnRequestHandler](./kibana-plugin-server.onrequesthandler.md) + +## OnRequestHandler type + + +Signature: + +```typescript +export declare type OnRequestHandler = (req: KibanaRequest, t: OnRequestToolkit) => OnRequestResult | Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.md b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.md new file mode 100644 index 0000000000000..5fdbebbf37263 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.md @@ -0,0 +1,20 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) + +## OnRequestToolkit interface + +A tool set defining an outcome of OnRequest interceptor for incoming request. + +Signature: + +```typescript +export interface OnRequestToolkit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [next](./kibana-plugin-server.onrequesttoolkit.next.md) | () => OnRequestResult | To pass request to the next handler | +| [redirected](./kibana-plugin-server.onrequesttoolkit.redirected.md) | (url: string) => OnRequestResult | To interrupt request handling and redirect to a configured url | +| [rejected](./kibana-plugin-server.onrequesttoolkit.rejected.md) | (error: Error, options?: {`

` statusCode?: number;`

` }) => OnRequestResult | Fail the request with specified error. | + diff --git a/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.next.md b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.next.md new file mode 100644 index 0000000000000..4a6aef813a566 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.next.md @@ -0,0 +1,11 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) > [next](./kibana-plugin-server.onrequesttoolkit.next.md) + +## OnRequestToolkit.next property + +To pass request to the next handler + +Signature: + +```typescript +next: () => OnRequestResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.redirected.md b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.redirected.md new file mode 100644 index 0000000000000..d2968d7b9c497 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.redirected.md @@ -0,0 +1,11 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) > [redirected](./kibana-plugin-server.onrequesttoolkit.redirected.md) + +## OnRequestToolkit.redirected property + +To interrupt request handling and redirect to a configured url + +Signature: + +```typescript +redirected: (url: string) => OnRequestResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.rejected.md b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.rejected.md new file mode 100644 index 0000000000000..c89c6ae5465d7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.rejected.md @@ -0,0 +1,13 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) > [rejected](./kibana-plugin-server.onrequesttoolkit.rejected.md) + +## OnRequestToolkit.rejected property + +Fail the request with specified error. + +Signature: + +```typescript +rejected: (error: Error, options?: { + statusCode?: number; + }) => OnRequestResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.http.md b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.http.md new file mode 100644 index 0000000000000..d66356117ce24 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.http.md @@ -0,0 +1,12 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [PluginSetupContext](./kibana-plugin-server.pluginsetupcontext.md) > [http](./kibana-plugin-server.pluginsetupcontext.http.md) + +## PluginSetupContext.http property + +Signature: + +```typescript +http: { + registerAuth: HttpServiceSetup['registerAuth']; + registerOnRequest: HttpServiceSetup['registerOnRequest']; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md index 6a4e1e16352aa..1df89009327f6 100644 --- a/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md +++ b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md @@ -15,4 +15,5 @@ export interface PluginSetupContext | Property | Type | Description | | --- | --- | --- | | [elasticsearch](./kibana-plugin-server.pluginsetupcontext.elasticsearch.md) | {`

` adminClient$: Observable<ClusterClient>;`

` dataClient$: Observable<ClusterClient>;`

` } | | +| [http](./kibana-plugin-server.pluginsetupcontext.http.md) | {`

` registerAuth: HttpServiceSetup['registerAuth'];`

` registerOnRequest: HttpServiceSetup['registerOnRequest'];`

` } | | diff --git a/docs/development/core/server/kibana-plugin-server.router.delete.md b/docs/development/core/server/kibana-plugin-server.router.delete.md new file mode 100644 index 0000000000000..40457d0645ddb --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.delete.md @@ -0,0 +1,23 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [delete](./kibana-plugin-server.router.delete.md) + +## Router.delete() method + +Register a `DELETE` request with the router + +Signature: + +```typescript +delete

(route: RouteConfig, handler: RequestHandler): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| route | RouteConfig<P, Q, B> | | +| handler | RequestHandler<P, Q, B> | | + +Returns: + +`void` + diff --git a/docs/development/core/server/kibana-plugin-server.router.get.md b/docs/development/core/server/kibana-plugin-server.router.get.md new file mode 100644 index 0000000000000..8ab429d411351 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.get.md @@ -0,0 +1,23 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [get](./kibana-plugin-server.router.get.md) + +## Router.get() method + +Register a `GET` request with the router + +Signature: + +```typescript +get

(route: RouteConfig, handler: RequestHandler): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| route | RouteConfig<P, Q, B> | | +| handler | RequestHandler<P, Q, B> | | + +Returns: + +`void` + diff --git a/docs/development/core/server/kibana-plugin-server.router.getroutes.md b/docs/development/core/server/kibana-plugin-server.router.getroutes.md new file mode 100644 index 0000000000000..eb48ca46d7282 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.getroutes.md @@ -0,0 +1,17 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [getRoutes](./kibana-plugin-server.router.getroutes.md) + +## Router.getRoutes() method + +Returns all routes registered with the this router. + +Signature: + +```typescript +getRoutes(): Readonly[]; +``` +Returns: + +`Readonly[]` + +List of registered routes. + diff --git a/docs/development/core/server/kibana-plugin-server.router.md b/docs/development/core/server/kibana-plugin-server.router.md new file mode 100644 index 0000000000000..8fc634b6a592b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.md @@ -0,0 +1,28 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) + +## Router class + + +Signature: + +```typescript +export declare class Router +``` + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [path](./kibana-plugin-server.router.path.md) | | string | | +| [routes](./kibana-plugin-server.router.routes.md) | | Array<Readonly<RouterRoute>> | | + +## Methods + +| Method | Modifiers | Description | +| --- | --- | --- | +| [delete(route, handler)](./kibana-plugin-server.router.delete.md) | | Register a DELETE request with the router | +| [get(route, handler)](./kibana-plugin-server.router.get.md) | | Register a GET request with the router | +| [getRoutes()](./kibana-plugin-server.router.getroutes.md) | | Returns all routes registered with the this router. | +| [post(route, handler)](./kibana-plugin-server.router.post.md) | | Register a POST request with the router | +| [put(route, handler)](./kibana-plugin-server.router.put.md) | | Register a PUT request with the router | + diff --git a/docs/development/core/server/kibana-plugin-server.router.path.md b/docs/development/core/server/kibana-plugin-server.router.path.md new file mode 100644 index 0000000000000..4344dcf4560e3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.path.md @@ -0,0 +1,9 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [path](./kibana-plugin-server.router.path.md) + +## Router.path property + +Signature: + +```typescript +readonly path: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.router.post.md b/docs/development/core/server/kibana-plugin-server.router.post.md new file mode 100644 index 0000000000000..929af38f0c662 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.post.md @@ -0,0 +1,23 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [post](./kibana-plugin-server.router.post.md) + +## Router.post() method + +Register a `POST` request with the router + +Signature: + +```typescript +post

(route: RouteConfig, handler: RequestHandler): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| route | RouteConfig<P, Q, B> | | +| handler | RequestHandler<P, Q, B> | | + +Returns: + +`void` + diff --git a/docs/development/core/server/kibana-plugin-server.router.put.md b/docs/development/core/server/kibana-plugin-server.router.put.md new file mode 100644 index 0000000000000..902104883262e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.put.md @@ -0,0 +1,23 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [put](./kibana-plugin-server.router.put.md) + +## Router.put() method + +Register a `PUT` request with the router + +Signature: + +```typescript +put

(route: RouteConfig, handler: RequestHandler): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| route | RouteConfig<P, Q, B> | | +| handler | RequestHandler<P, Q, B> | | + +Returns: + +`void` + diff --git a/docs/development/core/server/kibana-plugin-server.router.routes.md b/docs/development/core/server/kibana-plugin-server.router.routes.md new file mode 100644 index 0000000000000..a879bbc733b0c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.router.routes.md @@ -0,0 +1,9 @@ +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [routes](./kibana-plugin-server.router.routes.md) + +## Router.routes property + +Signature: + +```typescript +routes: Array>; +``` diff --git a/package.json b/package.json index 426f931043922..6f12759f765ef 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ }, "resolutions": { "**/@types/node": "10.12.27", + "**/@types/hapi": "^17.0.18", "**/typescript": "^3.3.3333" }, "workspaces": { @@ -286,6 +287,7 @@ "@types/globby": "^8.0.0", "@types/graphql": "^0.13.1", "@types/hapi": "^17.0.18", + "@types/hapi-auth-cookie": "^9.1.0", "@types/has-ansi": "^3.0.0", "@types/hoek": "^4.1.3", "@types/humps": "^1.1.2", @@ -313,6 +315,7 @@ "@types/react-virtualized": "^9.18.7", "@types/redux": "^3.6.31", "@types/redux-actions": "^2.2.1", + "@types/request": "^2.48.1", "@types/rimraf": "^2.0.2", "@types/semver": "^5.5.0", "@types/sinon": "^5.0.1", diff --git a/src/core/server/http/cookie_session_storage.ts b/src/core/server/http/cookie_session_storage.ts new file mode 100644 index 0000000000000..7301de6315606 --- /dev/null +++ b/src/core/server/http/cookie_session_storage.ts @@ -0,0 +1,78 @@ +/* + * 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, Server } from 'hapi'; +import hapiAuthCookie from 'hapi-auth-cookie'; +import { SessionStorageFactory, SessionStorage } from './session_storage'; + +export interface SessionStorageCookieOptions { + name: string; + encryptionKey: string; + validate: (sessionValue: T) => boolean | Promise; + isSecure: boolean; +} + +class ScopedCookieSessionStorage> implements SessionStorage { + constructor(private readonly server: Server, private readonly request: Request) {} + public async get(): Promise { + try { + return await this.server.auth.test('security-cookie', this.request); + } catch (error) { + return null; + } + } + public set(sessionValue: T) { + return this.request.cookieAuth.set(sessionValue); + } + public clear() { + return this.request.cookieAuth.clear(); + } +} + +/** + * Creates SessionStorage factory, which abstract the way of + * session storage implementation and scoping to the incoming requests. + * + * @param server - hapi server to create SessionStorage for + * @param cookieOptions - cookies configuration + */ +export async function createCookieSessionStorageFactory( + server: Server, + cookieOptions: SessionStorageCookieOptions, + basePath?: string +): Promise> { + await server.register({ plugin: hapiAuthCookie }); + + server.auth.strategy('security-cookie', 'cookie', { + cookie: cookieOptions.name, + password: cookieOptions.encryptionKey, + validateFunc: async (req, session: T) => ({ valid: await cookieOptions.validate(session) }), + isSecure: cookieOptions.isSecure, + path: basePath, + clearInvalid: true, + isHttpOnly: true, + isSameSite: false, + }); + + return { + asScoped(request: Request) { + return new ScopedCookieSessionStorage(server, request); + }, + }; +} diff --git a/src/core/server/http/cookie_sesson_storage.test.ts b/src/core/server/http/cookie_sesson_storage.test.ts new file mode 100644 index 0000000000000..398ef067e0f7a --- /dev/null +++ b/src/core/server/http/cookie_sesson_storage.test.ts @@ -0,0 +1,223 @@ +/* + * 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 { Server } from 'hapi'; +import request from 'request'; + +import { createCookieSessionStorageFactory } from './cookie_session_storage'; + +interface User { + id: string; + roles?: string[]; +} + +interface Storage { + value: User; + expires: number; +} + +function retrieveSessionCookie(cookies: string) { + const sessionCookie = request.cookie(cookies); + if (!sessionCookie) { + throw new Error('session cookie expected to be defined'); + } + return sessionCookie; +} + +const userData = { id: '42' }; +const sessionDurationMs = 30; +const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); +const cookieOptions = { + name: 'sid', + encryptionKey: 'something_at_least_32_characters', + validate: (session: Storage) => session.expires > Date.now(), + isSecure: false, + path: '/', +}; + +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 response = await server.inject('/set'); + expect(response.statusCode).toBe(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).toBeDefined(); + expect(cookies).toHaveLength(1); + + const sessionCookie = retrieveSessionCookie(cookies[0]); + expect(sessionCookie).toBeDefined(); + expect(sessionCookie.key).toBe('sid'); + expect(sessionCookie.value).toBeDefined(); + expect(sessionCookie.path).toBe('/'); + expect(sessionCookie.httpOnly).toBe(true); + }); + }); + 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 response = await server.inject('/get'); + expect(response.statusCode).toBe(200); + + const cookies = response.headers['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); + }); + 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 response = await server.inject('/get-empty'); + expect(response.statusCode).toBe(200); + expect(response.result).toBe('null'); + + const cookies = response.headers['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); + 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)); + }, + }, + }); + const response = await server.inject('/get-invalid'); + expect(response.statusCode).toBe(200); + + const cookies = response.headers['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 cookies2 = response2.headers['set-cookie']; + expect(cookies2).toEqual([ + 'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/', + ]); + }); + }); + 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 response = await server.inject('/clear'); + const cookies = response.headers['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 cookies2 = response2.headers['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 89c2c10afcef8..b8b740796a932 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -571,3 +571,22 @@ test('returns server and connection options on start', async () => { expect(innerServer).toBe((server as any).server); expect(options).toMatchSnapshot(); }); + +test('registers auth request interceptor only once', async () => { + const { registerAuth } = await server.start(config); + const doRegister = () => + registerAuth(() => null as any, { + encryptionKey: 'any_password', + } as any); + + await doRegister(); + expect(doRegister()).rejects.toThrowError('Auth interceptor was already registered'); +}); + +test('registers onRequest interceptor several times', async () => { + const { registerOnRequest } = await server.start(config); + const doRegister = () => registerOnRequest(() => null as any); + + doRegister(); + expect(doRegister).not.toThrowError(); +}); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 7b7e415415b30..7259677d5faca 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -23,16 +23,37 @@ import { modifyUrl } from '../../utils'; import { Logger } from '../logging'; import { HttpConfig } from './http_config'; import { createServer, getServerOptions } from './http_tools'; +import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; +import { adoptToHapiOnRequestFormat, OnRequestHandler } from './lifecycle/on_request'; import { Router } from './router'; +import { + SessionStorageCookieOptions, + createCookieSessionStorageFactory, +} from './cookie_session_storage'; export interface HttpServerInfo { server: Server; options: ServerOptions; + /** + * Define custom authentication and/or authorization mechanism for incoming requests. + * Applied to all resources by default. Only one AuthenticationHandler can be registered. + */ + registerAuth: ( + authenticationHandler: AuthenticationHandler, + cookieOptions: SessionStorageCookieOptions + ) => void; + /** + * Define custom logic to perform for incoming requests. + * Applied to all resources by default. + * Can register any number of OnRequestHandlers, which are called in sequence (from the first registered to the last) + */ + registerOnRequest: (requestHandler: OnRequestHandler) => void; } export class HttpServer { private server?: Server; - private registeredRouters: Set = new Set(); + private registeredRouters = new Set(); + private authRegistered = false; constructor(private readonly log: Logger) {} @@ -48,7 +69,7 @@ export class HttpServer { this.registeredRouters.add(router); } - public async start(config: HttpConfig) { + public async start(config: HttpConfig): Promise { this.log.debug('starting http server'); const serverOptions = getServerOptions(config); @@ -77,7 +98,15 @@ export class HttpServer { // Return server instance with the connection options so that we can properly // bridge core and the "legacy" Kibana internally. Once this bridge isn't // needed anymore we shouldn't return anything from this method. - return { server: this.server, options: serverOptions }; + return { + server: this.server, + options: serverOptions, + registerOnRequest: this.registerOnRequest.bind(this), + registerAuth: ( + fn: AuthenticationHandler, + cookieOptions: SessionStorageCookieOptions + ) => this.registerAuth(fn, cookieOptions, config.basePath), + }; } public async stop() { @@ -127,4 +156,43 @@ export class HttpServer { const routePathStartIndex = routerPath.endsWith('/') && routePath.startsWith('/') ? 1 : 0; return `${routerPath}${routePath.slice(routePathStartIndex)}`; } + + private registerOnRequest(fn: OnRequestHandler) { + if (this.server === undefined) { + throw new Error('Server is not created yet'); + } + + this.server.ext('onRequest', adoptToHapiOnRequestFormat(fn)); + } + + private async registerAuth( + fn: AuthenticationHandler, + cookieOptions: SessionStorageCookieOptions, + basePath?: string + ) { + if (this.server === undefined) { + throw new Error('Server is not created yet'); + } + if (this.authRegistered) { + throw new Error('Auth interceptor was already registered'); + } + this.authRegistered = true; + + const sessionStorage = await createCookieSessionStorageFactory( + this.server, + cookieOptions, + basePath + ); + + this.server.auth.scheme('login', () => ({ + authenticate: adoptToHapiAuthFormat(fn, sessionStorage), + })); + this.server.auth.strategy('session', 'login'); + + // The default means that the `session` strategy that is based on `login` schema defined above will be + // automatically assigned to all routes that don't contain an auth config. + // should be applied for all routes if they don't specify auth strategy in route declaration + // https://github.com/hapijs/hapi/blob/master/API.md#-serverauthdefaultoptions + this.server.auth.default('session'); + } } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index bc9a42b26fdff..d8bab493a2ed9 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -25,6 +25,8 @@ const createSetupContractMock = () => { // we can mock some hapi server method when we need it server: {} as Server, options: {} as ServerOptions, + registerAuth: jest.fn(), + registerOnRequest: jest.fn(), }; return setupContract; }; diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 848b3f88e0535..c136a7b20b361 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -27,7 +27,7 @@ import { HttpServer, HttpServerInfo } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; import { Router } from './router'; -/** @internal */ +/** @public */ export type HttpServiceSetup = HttpServerInfo; /** @internal */ diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 2c0dbf2488373..9457a3fad3c3c 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -22,3 +22,5 @@ export { HttpService, HttpServiceSetup } from './http_service'; export { Router, KibanaRequest } from './router'; export { HttpServerInfo } from './http_server'; export { BasePathProxyServer } from './base_path_proxy_server'; +export { AuthenticationHandler, AuthToolkit } from './lifecycle/auth'; +export { OnRequestHandler, OnRequestToolkit } from './lifecycle/on_request'; diff --git a/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/kibana.json b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/kibana.json new file mode 100644 index 0000000000000..0499e47abf9c3 --- /dev/null +++ b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "dummy-on-request", + "version": "0.0.1", + "kibanaVersion": "kibana", + "ui": false, + "server": true +} diff --git a/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/server/index.ts b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/server/index.ts new file mode 100644 index 0000000000000..9730472c8f84c --- /dev/null +++ b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/server/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { DummyOnRequestPlugin } from './plugin'; +export const plugin = () => new DummyOnRequestPlugin(); diff --git a/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/server/plugin.ts b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/server/plugin.ts new file mode 100644 index 0000000000000..42db041363ea0 --- /dev/null +++ b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_on_request/server/plugin.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 { CoreSetup } from '../../../../../..'; + +export const url = { + exception: '/exception', + failed: '/failed', + independentReq: '/independent-request', + root: '/', + redirect: '/redirect', + redirectTo: '/redirect-to', +}; + +export class DummyOnRequestPlugin { + public setup(core: CoreSetup) { + core.http.registerOnRequest(async (request, t) => { + await Promise.resolve(); + if (request.path === url.redirect) { + return t.redirected(url.redirectTo); + } + return t.next(); + }); + + core.http.registerOnRequest((request, t) => { + if (request.path === url.failed) { + return t.rejected(new Error('unexpected error'), { statusCode: 400 }); + } + return t.next(); + }); + + core.http.registerOnRequest((request, t) => { + if (request.path === url.exception) { + throw new Error('sensitive info'); + } + return t.next(); + }); + + core.http.registerOnRequest((request, t) => { + if (request.path === url.independentReq) { + // @ts-ignore. don't complain customField is not defined on Request type + request.customField = { value: 42 }; + } + return t.next(); + }); + + core.http.registerOnRequest((request, t) => { + if ( + request.path === url.independentReq && + // @ts-ignore don't complain customField is not defined on Request type + typeof request.customField !== 'undefined' + ) { + throw new Error('Request object was mutated'); + } + return t.next(); + }); + } +} diff --git a/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/kibana.json b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/kibana.json new file mode 100644 index 0000000000000..b6e84959322a9 --- /dev/null +++ b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "dummy-security", + "version": "0.0.1", + "kibanaVersion": "kibana", + "ui": false, + "server": true +} diff --git a/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/server/index.ts b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/server/index.ts new file mode 100644 index 0000000000000..dd78ab308a8bc --- /dev/null +++ b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/server/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { DummySecurityPlugin } from './plugin'; +export const plugin = () => new DummySecurityPlugin(); diff --git a/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/server/plugin.ts b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/server/plugin.ts new file mode 100644 index 0000000000000..a2abb20900e19 --- /dev/null +++ b/src/core/server/http/integration_tests/__fixtures__/plugins/dummy_security/server/plugin.ts @@ -0,0 +1,74 @@ +/* + * 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 Boom from 'boom'; +import { AuthenticationHandler, CoreSetup } from '../../../../../../../../core/server'; + +interface User { + id: string; + roles?: string[]; +} + +interface Storage { + value: User; + expires: number; +} + +export const url = { + auth: '/auth', + authRedirect: '/auth/redirect', + exception: '/exception', + redirectTo: '/login', +}; + +export const sessionDurationMs = 30; +export class DummySecurityPlugin { + public setup(core: CoreSetup) { + const authenticate: AuthenticationHandler = async (request, sessionStorage, t) => { + if (request.path === url.authRedirect) { + return t.redirected(url.redirectTo); + } + + if (request.path === url.exception) { + throw new Error('sensitive info'); + } + + if (request.headers.authorization) { + const user = { id: '42' }; + sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs }); + return t.authenticated({ credentials: user }); + } else { + return t.rejected(Boom.unauthorized()); + } + }; + + const cookieOptions = { + name: 'sid', + encryptionKey: 'something_at_least_32_characters', + validate: (session: Storage) => true, + isSecure: false, + path: '/', + }; + core.http.registerAuth(authenticate, cookieOptions); + return { + dummy() { + return 'Hello from dummy plugin'; + }, + }; + } +} diff --git a/src/core/server/http/integration_tests/http_service.test.ts b/src/core/server/http/integration_tests/http_service.test.ts new file mode 100644 index 0000000000000..199e103a3ed92 --- /dev/null +++ b/src/core/server/http/integration_tests/http_service.test.ts @@ -0,0 +1,163 @@ +/* + * 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 path from 'path'; +import request from 'request'; +import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import { Router } from '../router'; +import { url as authUrl } from './__fixtures__/plugins/dummy_security/server/plugin'; +import { url as onReqUrl } from './__fixtures__/plugins/dummy_on_request/server/plugin'; + +describe('http service', () => { + describe('setup contract', () => { + describe('#registerAuth()', () => { + const dummySecurityPlugin = path.resolve(__dirname, './__fixtures__/plugins/dummy_security'); + let root: ReturnType; + beforeAll(async () => { + root = kbnTestServer.createRoot( + { + plugins: { paths: [dummySecurityPlugin] }, + }, + { + dev: true, + } + ); + + const router = new Router(''); + router.get({ path: authUrl.auth, validate: false }, async (req, res) => + res.ok({ content: 'ok' }) + ); + // TODO fix me when registerRouter is available before HTTP server is run + (root as any).server.http.registerRouter(router); + + await root.setup(); + }, 30000); + + afterAll(async () => await root.shutdown()); + + it('Should support implementing custom authentication logic', async () => { + const response = await kbnTestServer.request + .get(root, authUrl.auth) + .expect(200, { content: 'ok' }); + + expect(response.header['set-cookie']).toBeDefined(); + const cookies = response.header['set-cookie']; + expect(cookies).toHaveLength(1); + + const sessionCookie = request.cookie(cookies[0]); + if (!sessionCookie) { + throw new Error('session cookie expected to be defined'); + } + expect(sessionCookie).toBeDefined(); + expect(sessionCookie.key).toBe('sid'); + expect(sessionCookie.value).toBeDefined(); + expect(sessionCookie.path).toBe('/'); + expect(sessionCookie.httpOnly).toBe(true); + }); + + it('Should support rejecting a request from an unauthenticated user', async () => { + await kbnTestServer.request + .get(root, authUrl.auth) + .unset('Authorization') + .expect(401); + }); + + it('Should support redirecting', async () => { + const response = await kbnTestServer.request.get(root, authUrl.authRedirect).expect(302); + expect(response.header.location).toBe(authUrl.redirectTo); + }); + + it('Should run auth for legacy routes and proxy request to legacy server route handlers', async () => { + const legacyUrl = '/legacy'; + const kbnServer = kbnTestServer.getKbnServer(root); + kbnServer.server.route({ + method: 'GET', + path: legacyUrl, + handler: () => 'ok from legacy server', + }); + + const response = await kbnTestServer.request + .get(root, legacyUrl) + .expect(200, 'ok from legacy server'); + + expect(response.header['set-cookie']).toBe(undefined); + }); + + it(`Shouldn't expose internal error details`, async () => { + await kbnTestServer.request.get(root, authUrl.exception).expect({ + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + }); + }); + }); + + describe('#registerOnRequest()', () => { + const dummyOnRequestPlugin = path.resolve( + __dirname, + './__fixtures__/plugins/dummy_on_request' + ); + let root: ReturnType; + beforeAll(async () => { + root = kbnTestServer.createRoot( + { + plugins: { paths: [dummyOnRequestPlugin] }, + }, + { + dev: true, + } + ); + + const router = new Router(''); + // routes with expected success status response should have handlers + [onReqUrl.root, onReqUrl.independentReq].forEach(url => + router.get({ path: url, validate: false }, async (req, res) => res.ok({ content: 'ok' })) + ); + // TODO fix me when registerRouter is available before HTTP server is run + (root as any).server.http.registerRouter(router); + + await root.setup(); + }, 30000); + + afterAll(async () => await root.shutdown()); + it('Should support passing request through to the route handler', async () => { + await kbnTestServer.request.get(root, onReqUrl.root).expect(200, { content: 'ok' }); + }); + it('Should support redirecting to configured url', async () => { + const response = await kbnTestServer.request.get(root, onReqUrl.redirect).expect(302); + expect(response.header.location).toBe(onReqUrl.redirectTo); + }); + it('Should failing a request with configured error and status code', async () => { + await kbnTestServer.request + .get(root, onReqUrl.failed) + .expect(400, { statusCode: 400, error: 'Bad Request', message: 'unexpected error' }); + }); + it(`Shouldn't expose internal error details`, async () => { + await kbnTestServer.request.get(root, onReqUrl.exception).expect({ + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + }); + }); + it(`Shouldn't share request object between interceptors`, async () => { + await kbnTestServer.request.get(root, onReqUrl.independentReq).expect(200); + }); + }); + }); +}); diff --git a/src/core/server/http/lifecycle/auth.test.ts b/src/core/server/http/lifecycle/auth.test.ts new file mode 100644 index 0000000000000..b8c0c7c5d1d50 --- /dev/null +++ b/src/core/server/http/lifecycle/auth.test.ts @@ -0,0 +1,103 @@ +/* + * 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 Boom from 'boom'; +import { adoptToHapiAuthFormat } from './auth'; + +const SessionStorageMock = { + asScoped: () => null as any, +}; +const requestMock = {} as any; +const createResponseToolkit = (customization = {}): any => ({ ...customization }); + +describe('adoptToHapiAuthFormat', () => { + it('Should allow authenticating a user identity with given credentials', async () => { + const credentials = {}; + const authenticatedMock = jest.fn(); + const onAuth = adoptToHapiAuthFormat( + async (req, sessionStorage, t) => t.authenticated(credentials), + SessionStorageMock + ); + await onAuth( + requestMock, + createResponseToolkit({ + authenticated: authenticatedMock, + }) + ); + + expect(authenticatedMock).toBeCalledTimes(1); + expect(authenticatedMock).toBeCalledWith({ credentials }); + }); + + it('Should allow redirecting to specified url', async () => { + const redirectUrl = '/docs'; + const onAuth = adoptToHapiAuthFormat( + async (req, sessionStorage, t) => t.redirected(redirectUrl), + SessionStorageMock + ); + const takeoverSymbol = {}; + const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol })); + const result = await onAuth( + requestMock, + createResponseToolkit({ + redirect: redirectMock, + }) + ); + + expect(redirectMock).toBeCalledWith(redirectUrl); + expect(result).toBe(takeoverSymbol); + }); + + it('Should allow to specify statusCode and message for Boom error', async () => { + const onAuth = adoptToHapiAuthFormat( + async (req, sessionStorage, t) => t.rejected(new Error('not found'), { statusCode: 404 }), + SessionStorageMock + ); + const result = (await onAuth(requestMock, createResponseToolkit())) as Boom; + + expect(result).toBeInstanceOf(Boom); + expect(result.message).toBe('not found'); + expect(result.output.statusCode).toBe(404); + }); + + it('Should return Boom.internal error error if interceptor throws', async () => { + const onAuth = adoptToHapiAuthFormat(async (req, sessionStorage, t) => { + throw new Error('unknown error'); + }, SessionStorageMock); + const result = (await onAuth(requestMock, createResponseToolkit())) as Boom; + + expect(result).toBeInstanceOf(Boom); + expect(result.message).toBe('unknown error'); + expect(result.output.statusCode).toBe(500); + }); + + it('Should return Boom.internal error if interceptor returns unexpected result', async () => { + const onAuth = adoptToHapiAuthFormat( + async (req, sessionStorage, t) => undefined as any, + SessionStorageMock + ); + const result = (await onAuth(requestMock, createResponseToolkit())) as Boom; + + expect(result).toBeInstanceOf(Boom); + expect(result.message).toBe( + 'Unexpected result from Authenticate. Expected AuthResult, but given: undefined.' + ); + expect(result.output.statusCode).toBe(500); + }); +}); diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts new file mode 100644 index 0000000000000..8205d21c5ff59 --- /dev/null +++ b/src/core/server/http/lifecycle/auth.ts @@ -0,0 +1,112 @@ +/* + * 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 Boom from 'boom'; +import { Lifecycle, Request, ResponseToolkit } from 'hapi'; +import { SessionStorage, SessionStorageFactory } from '../session_storage'; + +enum ResultType { + authenticated = 'authenticated', + redirected = 'redirected', + rejected = 'rejected', +} + +/** @internal */ +class AuthResult { + public static authenticated(credentials: any) { + return new AuthResult(ResultType.authenticated, credentials); + } + public static redirected(url: string) { + return new AuthResult(ResultType.redirected, url); + } + public static rejected(error: Error, options: { statusCode?: number } = {}) { + return new AuthResult(ResultType.rejected, { error, statusCode: options.statusCode }); + } + public static isValidResult(candidate: any) { + return candidate instanceof AuthResult; + } + constructor(private readonly type: ResultType, public readonly payload: any) {} + public isAuthenticated() { + return this.type === ResultType.authenticated; + } + public isRedirected() { + return this.type === ResultType.redirected; + } + public isRejected() { + return this.type === ResultType.rejected; + } +} + +/** + * @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: (credentials: any) => 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. */ + rejected: (error: Error, options?: { statusCode?: number }) => AuthResult; +} + +const toolkit: AuthToolkit = { + authenticated: AuthResult.authenticated, + redirected: AuthResult.redirected, + rejected: AuthResult.rejected, +}; + +/** @public */ +export type AuthenticationHandler = ( + request: Request, + sessionStorage: SessionStorage, + t: AuthToolkit +) => Promise; + +/** @public */ +export function adoptToHapiAuthFormat( + fn: AuthenticationHandler, + sessionStorage: SessionStorageFactory +) { + return async function interceptAuth( + req: Request, + h: ResponseToolkit + ): Promise { + try { + const result = await fn(req, sessionStorage.asScoped(req), toolkit); + + if (AuthResult.isValidResult(result)) { + if (result.isAuthenticated()) { + return h.authenticated({ credentials: result.payload }); + } + if (result.isRedirected()) { + return h.redirect(result.payload).takeover(); + } + if (result.isRejected()) { + const { error, statusCode } = result.payload; + return Boom.boomify(error, { statusCode }); + } + } + throw new Error( + `Unexpected result from Authenticate. Expected AuthResult, but given: ${result}.` + ); + } catch (error) { + return Boom.internal(error.message, { statusCode: 500 }); + } + }; +} diff --git a/src/core/server/http/lifecycle/on_request.test.ts b/src/core/server/http/lifecycle/on_request.test.ts new file mode 100644 index 0000000000000..bc4410c773288 --- /dev/null +++ b/src/core/server/http/lifecycle/on_request.test.ts @@ -0,0 +1,88 @@ +/* + * 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 Boom from 'boom'; +import { adoptToHapiOnRequestFormat } from './on_request'; + +const requestMock = {} as any; +const createResponseToolkit = (customization = {}): any => ({ ...customization }); + +describe('adoptToHapiOnRequestFormat', () => { + it('Should allow passing request to the next handler', async () => { + const continueSymbol = {}; + const onRequest = adoptToHapiOnRequestFormat((req, t) => t.next()); + const result = await onRequest( + requestMock, + createResponseToolkit({ + ['continue']: continueSymbol, + }) + ); + + expect(result).toBe(continueSymbol); + }); + + it('Should support redirecting to specified url', async () => { + const redirectUrl = '/docs'; + const onRequest = adoptToHapiOnRequestFormat((req, t) => t.redirected(redirectUrl)); + const takeoverSymbol = {}; + const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol })); + const result = await onRequest( + requestMock, + createResponseToolkit({ + redirect: redirectMock, + }) + ); + + expect(redirectMock).toBeCalledWith(redirectUrl); + expect(result).toBe(takeoverSymbol); + }); + + it('Should support specifying statusCode and message for Boom error', async () => { + const onRequest = adoptToHapiOnRequestFormat((req, t) => { + return t.rejected(new Error('unexpected result'), { statusCode: 501 }); + }); + const result = (await onRequest(requestMock, createResponseToolkit())) as Boom; + + expect(result).toBeInstanceOf(Boom); + expect(result.message).toBe('unexpected result'); + expect(result.output.statusCode).toBe(501); + }); + + it('Should return Boom.internal error if interceptor throws', async () => { + const onRequest = adoptToHapiOnRequestFormat((req, t) => { + throw new Error('unknown error'); + }); + const result = (await onRequest(requestMock, createResponseToolkit())) as Boom; + + expect(result).toBeInstanceOf(Boom); + expect(result.message).toBe('unknown error'); + expect(result.output.statusCode).toBe(500); + }); + + it('Should return Boom.internal error if interceptor returns unexpected result', async () => { + const onRequest = adoptToHapiOnRequestFormat((req, toolkit) => undefined as any); + const result = (await onRequest(requestMock, createResponseToolkit())) as Boom; + + expect(result).toBeInstanceOf(Boom); + expect(result.message).toBe( + 'Unexpected result from OnRequest. Expected OnRequestResult, but given: undefined.' + ); + expect(result.output.statusCode).toBe(500); + }); +}); diff --git a/src/core/server/http/lifecycle/on_request.ts b/src/core/server/http/lifecycle/on_request.ts new file mode 100644 index 0000000000000..6192a1dae682c --- /dev/null +++ b/src/core/server/http/lifecycle/on_request.ts @@ -0,0 +1,114 @@ +/* + * 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 Boom from 'boom'; +import { Lifecycle, Request, ResponseToolkit } from 'hapi'; +import { KibanaRequest } from '../router'; + +enum ResultType { + next = 'next', + redirected = 'redirected', + rejected = 'rejected', +} + +/** @internal */ +class OnRequestResult { + public static next() { + return new OnRequestResult(ResultType.next); + } + public static redirected(url: string) { + return new OnRequestResult(ResultType.redirected, url); + } + public static rejected(error: Error, options: { statusCode?: number } = {}) { + return new OnRequestResult(ResultType.rejected, { error, statusCode: options.statusCode }); + } + public static isValidResult(candidate: any) { + return candidate instanceof OnRequestResult; + } + constructor(private readonly type: ResultType, public readonly payload?: any) {} + public isNext() { + return this.type === ResultType.next; + } + public isRedirected() { + return this.type === ResultType.redirected; + } + public isRejected() { + return this.type === ResultType.rejected; + } +} + +/** + * @public + * A tool set defining an outcome of OnRequest interceptor for incoming request. + */ +export interface OnRequestToolkit { + /** To pass request to the next handler */ + next: () => OnRequestResult; + /** To interrupt request handling and redirect to a configured url */ + redirected: (url: string) => OnRequestResult; + /** Fail the request with specified error. */ + rejected: (error: Error, options?: { statusCode?: number }) => OnRequestResult; +} + +const toolkit: OnRequestToolkit = { + next: OnRequestResult.next, + redirected: OnRequestResult.redirected, + rejected: OnRequestResult.rejected, +}; + +/** @public */ +export type OnRequestHandler = ( + req: KibanaRequest, + t: OnRequestToolkit +) => OnRequestResult | Promise; + +/** + * @public + * Adopt custom request interceptor to Hapi lifecycle system. + * @param fn - an extension point allowing to perform custom logic for + * incoming HTTP requests. + */ +export function adoptToHapiOnRequestFormat(fn: OnRequestHandler) { + return async function interceptRequest( + req: Request, + h: ResponseToolkit + ): Promise { + try { + const result = await fn(KibanaRequest.from(req, undefined), toolkit); + if (OnRequestResult.isValidResult(result)) { + if (result.isNext()) { + return h.continue; + } + if (result.isRedirected()) { + return h.redirect(result.payload).takeover(); + } + if (result.isRejected()) { + const { error, statusCode } = result.payload; + return Boom.boomify(error, { statusCode }); + } + } + + throw new Error( + `Unexpected result from OnRequest. Expected OnRequestResult, but given: ${result}.` + ); + } catch (error) { + return Boom.internal(error.message, { statusCode: 500 }); + } + }; +} diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 8ee07eac2cca3..69de94e5fc6da 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -23,6 +23,7 @@ import { Request } from 'hapi'; import { filterHeaders, Headers } from './headers'; import { RouteSchemas } from './route'; +/** @public */ export class KibanaRequest { /** * Factory for creating requests. Validates the request before creating an @@ -68,9 +69,11 @@ export class KibanaRequest { } public readonly headers: Headers; + public readonly path: string; constructor(req: Request, readonly params: Params, readonly query: Query, readonly body: Body) { this.headers = req.headers; + this.path = req.path; } public getFilteredHeaders(headersToKeep: string[]) { diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index 37bfe053f8181..e75045007e4fd 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -30,6 +30,7 @@ export interface RouterRoute { handler: (req: Request, responseToolkit: ResponseToolkit) => Promise; } +/** @public */ export class Router { public routes: Array> = []; diff --git a/src/core/server/http/session_storage.ts b/src/core/server/http/session_storage.ts new file mode 100644 index 0000000000000..4f9d28991fe78 --- /dev/null +++ b/src/core/server/http/session_storage.ts @@ -0,0 +1,42 @@ +/* + * 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'; +/** + * Provides an interface to store and retrieve data across requests. + */ +export interface SessionStorage { + /** + * Retrieves session value from the session storage. + */ + get(): Promise; + /** + * Puts current session value into the session storage. + * @param sessionValue - value to put + */ + set(sessionValue: T): void; + /** + * Clears current session. + */ + clear(): void; +} + +export interface SessionStorageFactory { + asScoped: (request: Request) => SessionStorage; +} diff --git a/src/core/server/index.ts b/src/core/server/index.ts index a1beb6114e722..535b4f4749aa3 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -30,6 +30,14 @@ export { ElasticsearchClientConfig, APICaller, } from './elasticsearch'; +export { + AuthenticationHandler, + AuthToolkit, + KibanaRequest, + OnRequestHandler, + OnRequestToolkit, + Router, +} from './http'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; export { @@ -48,4 +56,4 @@ export interface CoreSetup { plugins: PluginsServiceSetup; } -export { ElasticsearchServiceSetup, HttpServiceSetup, PluginsServiceSetup }; +export { HttpServiceSetup, ElasticsearchServiceSetup, PluginsServiceSetup }; diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index d8dda124ea5bb..e62177d6756e3 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -162,6 +162,7 @@ describe('once LegacyService is set up with connection info', () => { { server: { autoListen: true } }, { elasticsearch: setupDeps.elasticsearch, + http: setupDeps.http, serverOptions: { listener: expect.any(LegacyPlatformProxy), someAnotherOption: 'bar', @@ -187,6 +188,7 @@ describe('once LegacyService is set up with connection info', () => { { server: { autoListen: true } }, { elasticsearch: setupDeps.elasticsearch, + http: setupDeps.http, serverOptions: { listener: expect.any(LegacyPlatformProxy), someAnotherOption: 'bar', diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 3a24ef03ccf6a..ffbb0f1edf711 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -89,7 +89,6 @@ export class LegacyService implements CoreService { await this.createClusterManager(config); return; } - return await this.createKbnServer(config, deps); }) ) @@ -148,6 +147,7 @@ export class LegacyService implements CoreService { } : { autoListen: false }, handledConfigPaths: await this.coreContext.configService.getUsedPaths(), + http, elasticsearch, plugins, }); diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index af438ff3759d3..98e02080e045b 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -22,6 +22,7 @@ import { Observable } from 'rxjs'; import { ConfigWithSchema, EnvironmentMode } from '../config'; import { CoreContext } from '../core_context'; import { ClusterClient } from '../elasticsearch'; +import { HttpServiceSetup } from '../http'; import { LoggerFactory } from '../logging'; import { PluginWrapper, PluginManifest } from './plugin'; import { PluginsServiceSetupDeps } from './plugins_service'; @@ -54,6 +55,10 @@ export interface PluginSetupContext { adminClient$: Observable; dataClient$: Observable; }; + http: { + registerAuth: HttpServiceSetup['registerAuth']; + registerOnRequest: HttpServiceSetup['registerOnRequest']; + }; } /** @@ -109,6 +114,12 @@ export function createPluginInitializerContext( }; } +// Added to improve http typings as make { http: Required } +// Http service is disabled, when Kibana runs in optimizer mode or as dev cluster managed by cluster master. +// In theory no plugins shouldn try to access http dependency in this case. +function preventAccess() { + throw new Error('Cannot use http contract when http server not started'); +} /** * This returns a facade for `CoreContext` that will be exposed to the plugin `setup` method. * This facade should be safe to use only within `setup` itself. @@ -133,5 +144,14 @@ export function createPluginSetupContext( adminClient$: deps.elasticsearch.adminClient$, dataClient$: deps.elasticsearch.dataClient$, }, + http: deps.http + ? { + registerAuth: deps.http.registerAuth, + registerOnRequest: deps.http.registerOnRequest, + } + : { + registerAuth: preventAccess, + registerOnRequest: preventAccess, + }, }; } diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index dcea3bdf1508e..5be42727159dc 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -22,6 +22,7 @@ import { filter, first, mergeMap, tap, toArray } from 'rxjs/operators'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { ElasticsearchServiceSetup } from '../elasticsearch/elasticsearch_service'; +import { HttpServiceSetup } from '../http/http_service'; import { Logger } from '../logging'; import { discover, PluginDiscoveryError, PluginDiscoveryErrorType } from './discovery'; import { DiscoveredPlugin, DiscoveredPluginInternal, PluginWrapper, PluginName } from './plugin'; @@ -40,6 +41,7 @@ export interface PluginsServiceSetup { /** @internal */ export interface PluginsServiceSetupDeps { elasticsearch: ElasticsearchServiceSetup; + http?: HttpServiceSetup; } /** @internal */ diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 9b18734a4a5c0..96c68685d3bd3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -6,7 +6,12 @@ import { ConfigOptions } from 'elasticsearch'; import { Duration } from 'moment'; +import { ObjectType } from '@kbn/config-schema'; import { Observable } from 'rxjs'; +import { Request } from 'hapi'; +import { ResponseObject } from 'hapi'; +import { ResponseToolkit } from 'hapi'; +import { Schema } from '@kbn/config-schema'; import { Server } from 'hapi'; import { ServerOptions } from 'hapi'; import { Type } from '@kbn/config-schema'; @@ -15,6 +20,18 @@ import { TypeOf } from '@kbn/config-schema'; // @public (undocumented) export type APICaller = (endpoint: string, clientParams: Record, options?: CallAPIOptions) => Promise; +// @public (undocumented) +export type AuthenticationHandler = (request: Request, sessionStorage: SessionStorage, t: AuthToolkit) => Promise; + +// @public +export interface AuthToolkit { + authenticated: (credentials: any) => AuthResult; + redirected: (url: string) => AuthResult; + rejected: (error: Error, options?: { + statusCode?: number; + }) => AuthResult; +} + // @internal (undocumented) export function bootstrap({ configs, cliArgs, applyConfigOverrides, features, }: BootstrapArgs): Promise; @@ -90,9 +107,27 @@ export interface ElasticsearchServiceSetup { // @public (undocumented) export type Headers = Record; -// @internal (undocumented) +// @public (undocumented) export type HttpServiceSetup = HttpServerInfo; +// @public (undocumented) +export class KibanaRequest { + constructor(req: Request, params: Params, query: Query, body: Body); + // (undocumented) + readonly body: Body; + static from

(req: Request, routeSchemas: RouteSchemas | undefined): KibanaRequest; + // (undocumented) + getFilteredHeaders(headersToKeep: string[]): Pick, string>; + // (undocumented) + readonly headers: Headers; + // (undocumented) + readonly params: Params; + // (undocumented) + readonly path: string; + // (undocumented) + readonly query: Query; + } + // @public export interface Logger { debug(message: string, meta?: LogMeta): void; @@ -160,6 +195,18 @@ export interface LogRecord { timestamp: Date; } +// @public (undocumented) +export type OnRequestHandler = (req: KibanaRequest, t: OnRequestToolkit) => OnRequestResult | Promise; + +// @public +export interface OnRequestToolkit { + next: () => OnRequestResult; + redirected: (url: string) => OnRequestResult; + rejected: (error: Error, options?: { + statusCode?: number; + }) => OnRequestResult; +} + // @public export interface Plugin = {}> { // (undocumented) @@ -196,6 +243,11 @@ export interface PluginSetupContext { adminClient$: Observable; dataClient$: Observable; }; + // (undocumented) + http: { + registerAuth: HttpServiceSetup['registerAuth']; + registerOnRequest: HttpServiceSetup['registerOnRequest']; + }; } // @internal (undocumented) @@ -209,6 +261,20 @@ export interface PluginsServiceSetup { }; } +// @public (undocumented) +export class Router { + constructor(path: string); + delete

(route: RouteConfig, handler: RequestHandler): void; + get

(route: RouteConfig, handler: RequestHandler): void; + getRoutes(): Readonly[]; + // (undocumented) + readonly path: string; + post

(route: RouteConfig, handler: RequestHandler): void; + put

(route: RouteConfig, handler: RequestHandler): void; + // (undocumented) + routes: Array>; + } + // @public export class ScopedClusterClient { constructor(internalAPICaller: APICaller, scopedAPICaller: APICaller, headers?: Record | undefined); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 26be718bf2d65..08ab584f7d47f 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -70,6 +70,7 @@ export class Server { const pluginsSetup = await this.plugins.setup({ elasticsearch: elasticsearchServiceSetup, + http: httpSetup, }); await this.legacy.setup({ diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index e000e13524de7..75783d1cbd1ee 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -19,10 +19,12 @@ import { Server } from 'hapi'; -import { ConfigService } from '../../core/server/'; -import { ElasticsearchServiceSetup } from '../../core/server/'; -import { HttpServiceSetup } from '../../core/server/'; -import { PluginsServiceSetup } from '../../core/server/'; +import { + ElasticsearchServiceSetup, + HttpServiceSetup, + ConfigService, + PluginsServiceSetup, +} from '../../core/server'; import { ApmOssPlugin } from '../core_plugins/apm_oss'; import { CallClusterWithRequest, ElasticsearchPlugin } from '../core_plugins/elasticsearch'; @@ -66,6 +68,7 @@ export default class KbnServer { setup: { core: { elasticsearch: ElasticsearchServiceSetup; + http?: HttpServiceSetup; }; plugins: PluginsServiceSetup; }; diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index c571785dd51e9..c81e7382e1bb7 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -54,12 +54,12 @@ export default class KbnServer { this.rootDir = rootDir; this.settings = settings || {}; - const { plugins, elasticsearch, serverOptions, handledConfigPaths } = core; - + const { plugins, http, elasticsearch, serverOptions, handledConfigPaths } = core; this.newPlatform = { setup: { core: { elasticsearch, + http, }, plugins, }, diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts index 5326b11852354..330c058ed39ae 100644 --- a/src/test_utils/kbn_server.ts +++ b/src/test_utils/kbn_server.ts @@ -32,7 +32,7 @@ import { defaultsDeep, get } from 'lodash'; import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; import supertest from 'supertest'; -import { Env } from '../core/server/config'; +import { CliArgs, Env } from '../core/server/config'; import { LegacyObjectToConfigAdapter } from '../core/server/legacy'; import { Root } from '../core/server/root'; @@ -60,7 +60,10 @@ const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { }, }; -export function createRootWithSettings(...settings: Array>) { +export function createRootWithSettings( + settings: Record, + cliArgs: Partial = {} +) { const env = Env.createDefault({ configs: [], cliArgs: { @@ -72,13 +75,14 @@ export function createRootWithSettings(...settings: Array>) repl: false, basePath: false, optimize: false, + ...cliArgs, }, isDevClusterMaster: false, }); return new Root( new BehaviorSubject( - new LegacyObjectToConfigAdapter(defaultsDeep({}, ...settings, DEFAULTS_SETTINGS)) + new LegacyObjectToConfigAdapter(defaultsDeep({}, settings, DEFAULTS_SETTINGS)) ), env ); @@ -104,8 +108,8 @@ function getSupertest(root: Root, method: HttpMethod, path: string) { * @param {Object} [settings={}] Any config overrides for this instance. * @returns {Root} */ -export function createRoot(settings = {}) { - return createRootWithSettings(settings); +export function createRoot(settings = {}, cliArgs: Partial = {}) { + return createRootWithSettings(settings, cliArgs); } /** @@ -116,7 +120,7 @@ export function createRoot(settings = {}) { * @returns {Root} */ export function createRootWithCorePlugins(settings = {}) { - return createRootWithSettings(settings, DEFAULT_SETTINGS_WITH_CORE_PLUGINS); + return createRootWithSettings(defaultsDeep({}, settings, DEFAULT_SETTINGS_WITH_CORE_PLUGINS)); } /** diff --git a/yarn.lock b/yarn.lock index 659c2fed04a56..c9e1958df0ef8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2697,6 +2697,11 @@ resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.2.0.tgz#19c36cbb5811a7493f0f2e37f31d42b28df1abc1" integrity sha512-HonbGsHFbskh9zRAzA6tabcw18mCOsSEOL2ibGAuVqk6e7nElcRmWO5L4UfIHpDbWBWw+eZYFdsQ1+MEGgpcVA== +"@types/caseless@*": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" + integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== + "@types/catbox@*": version "10.0.1" resolved "https://registry.yarnpkg.com/@types/catbox/-/catbox-10.0.1.tgz#266679017749041fe9873fee1131dd2aaa04a07e" @@ -2887,7 +2892,7 @@ resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.0.tgz#cbb49815a5e1129d5f23836a98d65d93822409af" integrity sha512-dxdRrUov2HVTbSRFX+7xwUPlbGYVEZK6PrSqClg2QPos3PNe0bCajkDDkDeeC1znjSH03KOEqVbXpnJuWa2wgQ== -"@types/form-data@^2.2.1": +"@types/form-data@*", "@types/form-data@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e" integrity sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ== @@ -2945,9 +2950,9 @@ resolved "https://registry.yarnpkg.com/@types/hapi-auth-cookie/-/hapi-auth-cookie-9.1.0.tgz#cbcd2236b7d429bd0632a8cc45cfd355fdd7e7a2" integrity sha512-qsP08L+fNaE2K5dsDVKvHp0AmSBs8m9PD5eWsTdHnkJOk81iD7c0J4GYt/1aDJwZsyx6CgcxpbkPOCwBJmrwAg== dependencies: - "@types/hapi" "^17.0.18" + "@types/hapi" "*" -"@types/hapi@^17.0.18": +"@types/hapi@*", "@types/hapi@^17.0.18": version "17.0.18" resolved "https://registry.yarnpkg.com/@types/hapi/-/hapi-17.0.18.tgz#f855fe18766aa2592a3a689c3e6eabe72989ff1a" integrity sha512-sRoDjz1iVOCxTqq+EepzDQI773k2PjboHpvMpp524278grosStxZ5+oooVjNLJZj1iZIbiLeeR5/ZeIRgVXsCg== @@ -3392,6 +3397,16 @@ resolved "https://registry.yarnpkg.com/@types/redux/-/redux-3.6.31.tgz#40eafa7575db36b912ce0059b85de98c205b0708" integrity sha1-QOr6dXXbNrkSzgBZuF3pjCBbBwg= +"@types/request@^2.48.1": + version "2.48.1" + resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.1.tgz#e402d691aa6670fbbff1957b15f1270230ab42fa" + integrity sha512-ZgEZ1TiD+KGA9LiAAPPJL68Id2UWfeSO62ijSXZjFJArVV+2pKcsVHmrcu+1oiE3q6eDGiFiSolRc4JHoerBBg== + dependencies: + "@types/caseless" "*" + "@types/form-data" "*" + "@types/node" "*" + "@types/tough-cookie" "*" + "@types/retry@*", "@types/retry@^0.10.2": version "0.10.2" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37" @@ -3496,6 +3511,11 @@ resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.1.tgz#2f5670c9d1d6e558897a810ed284b44918fc1253" integrity sha512-25L/RL5tqZkquKXVHM1fM2bd23qjfbcPpAZ2N/H05Y45g3UEi+Hw8CbDV28shKY8gH1SHiLpZSxPI1lacqdpGg== +"@types/tough-cookie@*": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-2.3.5.tgz#9da44ed75571999b65c37b60c9b2b88db54c585d" + integrity sha512-SCcK7mvGi3+ZNz833RRjFIxrn4gI1PPR3NtuIS+6vMkvmsGjosqTJwRt5bAEFLRz+wtJMWv8+uOnZf2hi2QXTg== + "@types/type-detect@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/type-detect/-/type-detect-4.0.1.tgz#3b0f5ac82ea630090cbf57c57a1bf5a63a29b9b6"