From 4df8d89bc1d92501a6a87587a07da6c911cc2999 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 3 Jul 2019 13:36:20 +0200 Subject: [PATCH 01/13] Temporary Core workarounds. --- ...a-plugin-server.coresetup.elasticsearch.md | 5 +---- .../kibana-plugin-server.coresetup.http.md | 1 + .../server/kibana-plugin-server.coresetup.md | 4 ++-- .../server/kibana-plugin-server.deepfreeze.md | 22 +++++++++++++++++++ .../core/server/kibana-plugin-server.md | 6 +++++ src/core/server/http/http_server.ts | 2 ++ src/core/server/http/http_service.mock.ts | 1 + src/core/server/index.ts | 11 ++++------ src/core/server/mocks.ts | 2 +- src/core/server/plugins/plugin_context.ts | 3 +++ src/core/server/server.api.md | 12 ++++++---- 11 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-server.deepfreeze.md diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md b/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md index d837b614e58a00..f66cf883ebffb7 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md @@ -7,8 +7,5 @@ Signature: ```typescript -elasticsearch: { - adminClient$: Observable; - dataClient$: Observable; - }; +elasticsearch: ElasticsearchServiceSetup; ``` diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.http.md b/docs/development/core/server/kibana-plugin-server.coresetup.http.md index cba8a5832058d0..40c24112b729bf 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.http.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.http.md @@ -13,5 +13,6 @@ http: { registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; basePath: HttpServiceSetup['basePath']; createNewServer: HttpServiceSetup['createNewServer']; + isTLSEnabled: HttpServiceSetup['isTLSEnabled']; }; ``` diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.md b/docs/development/core/server/kibana-plugin-server.coresetup.md index 352e2f314da30f..cce37d667a05bb 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.md @@ -16,6 +16,6 @@ export interface CoreSetup | Property | Type | Description | | --- | --- | --- | -| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | {
adminClient$: Observable<ClusterClient>;
dataClient$: Observable<ClusterClient>;
} | | -| [http](./kibana-plugin-server.coresetup.http.md) | {
registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];
registerAuth: HttpServiceSetup['registerAuth'];
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
basePath: HttpServiceSetup['basePath'];
createNewServer: HttpServiceSetup['createNewServer'];
} | | +| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | | +| [http](./kibana-plugin-server.coresetup.http.md) | {
registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];
registerAuth: HttpServiceSetup['registerAuth'];
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
basePath: HttpServiceSetup['basePath'];
createNewServer: HttpServiceSetup['createNewServer'];
isTLSEnabled: HttpServiceSetup['isTLSEnabled'];
} | | diff --git a/docs/development/core/server/kibana-plugin-server.deepfreeze.md b/docs/development/core/server/kibana-plugin-server.deepfreeze.md new file mode 100644 index 00000000000000..5c55cc90fdca2b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.deepfreeze.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [deepFreeze](./kibana-plugin-server.deepfreeze.md) + +## deepFreeze() function + +Signature: + +```typescript +export declare function deepFreeze(object: T): RecursiveReadonly; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| object | T | | + +Returns: + +`RecursiveReadonly` + diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index ab79f2b3829094..0cf5bf7c4d4d2d 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -21,6 +21,12 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.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 | +## Functions + +| Function | Description | +| --- | --- | +| [deepFreeze(object)](./kibana-plugin-server.deepfreeze.md) | | + ## Interfaces | Interface | Description | diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 42ec5c04bea4b6..b11a05e9447cac 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -75,6 +75,7 @@ export interface HttpServerSetup { isAuthenticated: AuthStateStorage['isAuthenticated']; getAuthHeaders: AuthHeadersStorage['get']; }; + isTLSEnabled: boolean; } export class HttpServer { @@ -131,6 +132,7 @@ export class HttpServer { // bridge core and the "legacy" Kibana internally. Once this bridge isn't // needed anymore we shouldn't return the instance from this method. server: this.server, + isTLSEnabled: config.ssl.enabled, }; } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 9b241e8679318a..2b39b3b7939563 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -45,6 +45,7 @@ const createSetupContractMock = () => { getAuthHeaders: jest.fn(), }, createNewServer: jest.fn(), + isTLSEnabled: false, }; setupContract.createNewServer.mockResolvedValue({} as HttpServerSetup); return setupContract; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 50f4c24361a6a4..5de01809726753 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -35,8 +35,7 @@ * @packageDocumentation */ -import { Observable } from 'rxjs'; -import { ClusterClient, ElasticsearchServiceSetup } from './elasticsearch'; +import { ElasticsearchServiceSetup } from './elasticsearch'; import { HttpServiceSetup, HttpServiceStart } from './http'; import { PluginsServiceSetup, PluginsServiceStart } from './plugins'; @@ -104,7 +103,7 @@ export { SavedObjectsUpdateResponse, } from './saved_objects'; -export { RecursiveReadonly } from '../utils'; +export { RecursiveReadonly, deepFreeze } from '../utils'; /** * Context passed to the plugins `setup` method. @@ -112,16 +111,14 @@ export { RecursiveReadonly } from '../utils'; * @public */ export interface CoreSetup { - elasticsearch: { - adminClient$: Observable; - dataClient$: Observable; - }; + elasticsearch: ElasticsearchServiceSetup; http: { registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; basePath: HttpServiceSetup['basePath']; createNewServer: HttpServiceSetup['createNewServer']; + isTLSEnabled: HttpServiceSetup['isTLSEnabled']; }; } diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index af0eed6ba833d6..9dc180e5e69c9b 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -55,7 +55,7 @@ function pluginInitializerContextMock(config: T) { function createCoreSetupMock() { const mock: MockedKeys = { - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetupContract() as any, http: httpServiceMock.createSetupContract(), }; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 885b2b0e465522..282290223df7a7 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -115,6 +115,8 @@ export function createPluginSetupContext( elasticsearch: { adminClient$: deps.elasticsearch.adminClient$, dataClient$: deps.elasticsearch.dataClient$, + createClient: deps.elasticsearch.createClient, + legacy: deps.elasticsearch.legacy, }, http: { registerOnPreAuth: deps.http.registerOnPreAuth, @@ -122,6 +124,7 @@ export function createPluginSetupContext( registerOnPostAuth: deps.http.registerOnPostAuth, basePath: deps.http.basePath, createNewServer: deps.http.createNewServer, + isTLSEnabled: deps.http.isTLSEnabled, }, }; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b1fdc1289151ce..894a28e08cc44e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -86,10 +86,7 @@ export class ConfigService { // @public export interface CoreSetup { // (undocumented) - elasticsearch: { - adminClient$: Observable; - dataClient$: Observable; - }; + elasticsearch: ElasticsearchServiceSetup; // (undocumented) http: { registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; @@ -97,6 +94,7 @@ export interface CoreSetup { registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; basePath: HttpServiceSetup['basePath']; createNewServer: HttpServiceSetup['createNewServer']; + isTLSEnabled: HttpServiceSetup['isTLSEnabled']; }; } @@ -104,6 +102,12 @@ export interface CoreSetup { export interface CoreStart { } +// Warning: (ae-forgotten-export) The symbol "Freezable" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "deepFreeze" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function deepFreeze(object: T): RecursiveReadonly; + // @public export interface DiscoveredPlugin { readonly configPath: ConfigPath; From ba674e9281f8bf5165bbf6c6097797bedd32ccba Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 3 Jul 2019 13:54:30 +0200 Subject: [PATCH 02/13] Move files to NP Security Plugin. --- .../security/common/model/authenticated_user.test.ts | 0 .../plugins/security/common/model/authenticated_user.ts | 0 .../plugins/security/common/model/user.test.ts | 0 x-pack/{legacy => }/plugins/security/common/model/user.ts | 0 x-pack/plugins/security/kibana.json | 8 ++++++++ .../security/server/authentication/auth_redirect.test.ts} | 0 .../security/server/authentication/auth_redirect.ts} | 0 .../server}/authentication/authentication_result.test.ts | 0 .../server}/authentication/authentication_result.ts | 0 .../security/server}/authentication/authenticator.test.ts | 0 .../security/server}/authentication/authenticator.ts | 0 .../server/authentication}/can_redirect_request.test.ts | 0 .../server/authentication}/can_redirect_request.ts | 0 .../authentication/deauthentication_result.test.ts | 0 .../server}/authentication/deauthentication_result.ts | 0 .../security/server}/authentication/index.ts | 0 .../security/server}/authentication/login_attempt.test.ts | 0 .../security/server}/authentication/login_attempt.ts | 0 .../server}/authentication/providers/base.mock.ts | 0 .../security/server}/authentication/providers/base.ts | 0 .../server}/authentication/providers/basic.test.ts | 0 .../security/server}/authentication/providers/basic.ts | 0 .../security/server}/authentication/providers/index.ts | 0 .../server}/authentication/providers/kerberos.test.ts | 0 .../security/server}/authentication/providers/kerberos.ts | 0 .../server}/authentication/providers/oidc.test.ts | 0 .../security/server}/authentication/providers/oidc.ts | 0 .../server}/authentication/providers/saml.test.ts | 0 .../security/server}/authentication/providers/saml.ts | 0 .../server}/authentication/providers/token.test.ts | 0 .../security/server}/authentication/providers/token.ts | 0 .../security/server}/authentication/session.test.ts | 0 .../security/server}/authentication/session.ts | 0 .../security/server}/authentication/tokens.test.ts | 0 .../security/server}/authentication/tokens.ts | 0 .../security/server/config.test.ts} | 0 .../security/server/config.ts} | 0 .../server/lib => plugins/security/server}/errors.test.ts | 0 .../server/lib => plugins/security/server}/errors.ts | 0 39 files changed, 8 insertions(+) rename x-pack/{legacy => }/plugins/security/common/model/authenticated_user.test.ts (100%) rename x-pack/{legacy => }/plugins/security/common/model/authenticated_user.ts (100%) rename x-pack/{legacy => }/plugins/security/common/model/user.test.ts (100%) rename x-pack/{legacy => }/plugins/security/common/model/user.ts (100%) create mode 100644 x-pack/plugins/security/kibana.json rename x-pack/{legacy/plugins/security/server/lib/__tests__/auth_redirect.js => plugins/security/server/authentication/auth_redirect.test.ts} (100%) rename x-pack/{legacy/plugins/security/server/lib/auth_redirect.js => plugins/security/server/authentication/auth_redirect.ts} (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/authentication_result.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/authentication_result.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/authenticator.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/authenticator.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server/authentication}/can_redirect_request.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server/authentication}/can_redirect_request.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/deauthentication_result.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/deauthentication_result.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/index.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/login_attempt.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/login_attempt.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/base.mock.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/base.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/basic.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/basic.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/index.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/kerberos.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/kerberos.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/oidc.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/oidc.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/saml.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/saml.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/token.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/providers/token.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/session.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/session.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/tokens.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/authentication/tokens.ts (100%) rename x-pack/{legacy/plugins/security/server/lib/__tests__/validate_config.js => plugins/security/server/config.test.ts} (100%) rename x-pack/{legacy/plugins/security/server/lib/validate_config.js => plugins/security/server/config.ts} (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/errors.test.ts (100%) rename x-pack/{legacy/plugins/security/server/lib => plugins/security/server}/errors.ts (100%) diff --git a/x-pack/legacy/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/authenticated_user.test.ts rename to x-pack/plugins/security/common/model/authenticated_user.test.ts diff --git a/x-pack/legacy/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/authenticated_user.ts rename to x-pack/plugins/security/common/model/authenticated_user.ts diff --git a/x-pack/legacy/plugins/security/common/model/user.test.ts b/x-pack/plugins/security/common/model/user.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/user.test.ts rename to x-pack/plugins/security/common/model/user.test.ts diff --git a/x-pack/legacy/plugins/security/common/model/user.ts b/x-pack/plugins/security/common/model/user.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/user.ts rename to x-pack/plugins/security/common/model/user.ts diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json new file mode 100644 index 00000000000000..7ac9d654eb07ee --- /dev/null +++ b/x-pack/plugins/security/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "security", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "security"], + "server": true, + "ui": false +} diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/auth_redirect.js b/x-pack/plugins/security/server/authentication/auth_redirect.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/__tests__/auth_redirect.js rename to x-pack/plugins/security/server/authentication/auth_redirect.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/auth_redirect.js b/x-pack/plugins/security/server/authentication/auth_redirect.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/auth_redirect.js rename to x-pack/plugins/security/server/authentication/auth_redirect.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.test.ts b/x-pack/plugins/security/server/authentication/authentication_result.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.test.ts rename to x-pack/plugins/security/server/authentication/authentication_result.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts rename to x-pack/plugins/security/server/authentication/authentication_result.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts rename to x-pack/plugins/security/server/authentication/authenticator.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts rename to x-pack/plugins/security/server/authentication/authenticator.ts diff --git a/x-pack/legacy/plugins/security/server/lib/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/can_redirect_request.test.ts rename to x-pack/plugins/security/server/authentication/can_redirect_request.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/can_redirect_request.ts rename to x-pack/plugins/security/server/authentication/can_redirect_request.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.test.ts b/x-pack/plugins/security/server/authentication/deauthentication_result.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.test.ts rename to x-pack/plugins/security/server/authentication/deauthentication_result.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.ts b/x-pack/plugins/security/server/authentication/deauthentication_result.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.ts rename to x-pack/plugins/security/server/authentication/deauthentication_result.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/index.ts rename to x-pack/plugins/security/server/authentication/index.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts b/x-pack/plugins/security/server/authentication/login_attempt.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts rename to x-pack/plugins/security/server/authentication/login_attempt.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.ts b/x-pack/plugins/security/server/authentication/login_attempt.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.ts rename to x-pack/plugins/security/server/authentication/login_attempt.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts rename to x-pack/plugins/security/server/authentication/providers/base.mock.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts rename to x-pack/plugins/security/server/authentication/providers/base.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts rename to x-pack/plugins/security/server/authentication/providers/basic.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts rename to x-pack/plugins/security/server/authentication/providers/basic.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts rename to x-pack/plugins/security/server/authentication/providers/index.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts rename to x-pack/plugins/security/server/authentication/providers/kerberos.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts rename to x-pack/plugins/security/server/authentication/providers/kerberos.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts rename to x-pack/plugins/security/server/authentication/providers/oidc.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts rename to x-pack/plugins/security/server/authentication/providers/oidc.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts rename to x-pack/plugins/security/server/authentication/providers/saml.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts rename to x-pack/plugins/security/server/authentication/providers/saml.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts rename to x-pack/plugins/security/server/authentication/providers/token.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts rename to x-pack/plugins/security/server/authentication/providers/token.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts b/x-pack/plugins/security/server/authentication/session.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts rename to x-pack/plugins/security/server/authentication/session.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/session.ts b/x-pack/plugins/security/server/authentication/session.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/session.ts rename to x-pack/plugins/security/server/authentication/session.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/tokens.test.ts rename to x-pack/plugins/security/server/authentication/tokens.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts rename to x-pack/plugins/security/server/authentication/tokens.ts diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/validate_config.js b/x-pack/plugins/security/server/config.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/__tests__/validate_config.js rename to x-pack/plugins/security/server/config.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/validate_config.js b/x-pack/plugins/security/server/config.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/validate_config.js rename to x-pack/plugins/security/server/config.ts diff --git a/x-pack/legacy/plugins/security/server/lib/errors.test.ts b/x-pack/plugins/security/server/errors.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/errors.test.ts rename to x-pack/plugins/security/server/errors.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/errors.ts b/x-pack/plugins/security/server/errors.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/errors.ts rename to x-pack/plugins/security/server/errors.ts From 02dbfdb059dec8b39d483f6c2c99f817bb1dd027 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 3 Jul 2019 14:23:28 +0200 Subject: [PATCH 03/13] Fix references. --- .../legacy/plugins/security/common/model/index.ts | 7 +++++-- .../change_password_form.test.tsx | 2 +- .../change_password_form/change_password_form.tsx | 2 +- .../server/routes/api/external/roles/delete.js | 2 +- .../server/routes/api/external/roles/get.js | 2 +- .../server/routes/api/external/roles/put.js | 2 +- .../security/server/routes/api/v1/authenticate.js | 3 +-- .../security/server/routes/api/v1/indices.js | 2 +- .../security/server/routes/api/v1/users.js | 3 +-- x-pack/plugins/security/common/model/index.ts | 8 ++++++++ .../server/authentication/auth_redirect.test.ts | 4 ++-- .../server/authentication/auth_redirect.ts | 2 +- .../authentication/authentication_result.test.ts | 2 +- .../authentication/authentication_result.ts | 2 +- .../server/authentication/authenticator.test.ts | 2 +- .../server/authentication/authenticator.ts | 2 +- .../security/server/authentication/index.ts | 2 ++ .../server/authentication/providers/oidc.ts | 2 +- .../server/authentication/providers/saml.ts | 4 ++-- .../server/authentication/providers/token.ts | 2 +- x-pack/plugins/security/server/config.test.ts | 2 +- x-pack/plugins/security/server/index.ts | 15 +++++++++++++++ 22 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/security/common/model/index.ts create mode 100644 x-pack/plugins/security/server/index.ts diff --git a/x-pack/legacy/plugins/security/common/model/index.ts b/x-pack/legacy/plugins/security/common/model/index.ts index 83648cfb59a67f..76a8b0a61ea04f 100644 --- a/x-pack/legacy/plugins/security/common/model/index.ts +++ b/x-pack/legacy/plugins/security/common/model/index.ts @@ -8,5 +8,8 @@ export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; export { KibanaPrivileges } from './kibana_privileges'; -export { User, EditUser, getUserDisplayName } from './user'; -export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; +export { User, EditUser, getUserDisplayName } from '../../../../../plugins/security/common/model'; +export { + AuthenticatedUser, + canUserChangePassword, +} from '../../../../../plugins/security/common/model'; diff --git a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx index abd504c86bc518..221120532318cb 100644 --- a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx +++ b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx @@ -7,7 +7,7 @@ import { EuiFieldText } from '@elastic/eui'; import { ReactWrapper } from 'enzyme'; import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { User } from '../../../../common/model/user'; +import { User } from '../../../../common/model'; import { UserAPIClient } from '../../../lib/api'; import { ChangePasswordForm } from './change_password_form'; diff --git a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx index ad33b977126b45..9521cbdc58a788 100644 --- a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx +++ b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { ChangeEvent, Component } from 'react'; import { toastNotifications } from 'ui/notify'; -import { User } from '../../../../common/model/user'; +import { User } from '../../../../common/model'; import { UserAPIClient } from '../../../lib/api'; interface Props { diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js index ecba6c3f97e6a2..8568321ba19414 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js +++ b/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js @@ -5,7 +5,7 @@ */ import Joi from 'joi'; -import { wrapError } from '../../../../lib/errors'; +import { wrapError } from '../../../../../../../../plugins/security/server'; export function initDeleteRolesApi(server, callWithRequest, routePreCheckLicenseFn) { server.route({ diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js index d0594e32ba48cf..3540d9b7a883bf 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js +++ b/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js @@ -6,7 +6,7 @@ import _ from 'lodash'; import Boom from 'boom'; import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD } from '../../../../../common/constants'; -import { wrapError } from '../../../../lib/errors'; +import { wrapError } from '../../../../../../../../plugins/security/server'; import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application) { diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js index c04a4f19420a71..681d2220930ef1 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js +++ b/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js @@ -7,7 +7,7 @@ import { flatten, pick, identity, intersection } from 'lodash'; import Joi from 'joi'; import { GLOBAL_RESOURCE } from '../../../../../common/constants'; -import { wrapError } from '../../../../lib/errors'; +import { wrapError } from '../../../../../../../../plugins/security/server'; import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; export function initPutRolesApi( diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js index e24b31039b9cb8..8075b2e46fcd26 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js @@ -6,8 +6,7 @@ import Boom from 'boom'; import Joi from 'joi'; -import { wrapError } from '../../../lib/errors'; -import { canRedirectRequest } from '../../../lib/can_redirect_request'; +import { canRedirectRequest, wrapError } from '../../../../../../../plugins/security/server'; export function initAuthenticateApi(server) { diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js b/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js index 2625899b09f495..7265b83783fdd2 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js @@ -6,7 +6,7 @@ import _ from 'lodash'; import { getClient } from '../../../../../../server/lib/get_client_shield'; -import { wrapError } from '../../../lib/errors'; +import { wrapError } from '../../../../../../../plugins/security/server'; export function initIndicesApi(server) { const callWithRequest = getClient(server).callWithRequest; diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js index 0e2719e87707da..54aed121ca20d9 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js @@ -9,9 +9,8 @@ import Boom from 'boom'; import Joi from 'joi'; import { getClient } from '../../../../../../server/lib/get_client_shield'; import { userSchema } from '../../../lib/user_schema'; -import { wrapError } from '../../../lib/errors'; +import { BasicCredentials, wrapError } from '../../../../../../../plugins/security/server'; import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { BasicCredentials } from '../../../../server/lib/authentication/providers/basic'; export function initUsersApi(server) { const callWithRequest = getClient(server).callWithRequest; diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts new file mode 100644 index 00000000000000..00b17548c47ac2 --- /dev/null +++ b/x-pack/plugins/security/common/model/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { User, EditUser, getUserDisplayName } from './user'; +export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; diff --git a/x-pack/plugins/security/server/authentication/auth_redirect.test.ts b/x-pack/plugins/security/server/authentication/auth_redirect.test.ts index a96d4b5a008cc6..7d92a15532da29 100644 --- a/x-pack/plugins/security/server/authentication/auth_redirect.test.ts +++ b/x-pack/plugins/security/server/authentication/auth_redirect.test.ts @@ -12,8 +12,8 @@ import { hFixture } from './__fixtures__/h'; import { requestFixture } from './__fixtures__/request'; import { serverFixture } from './__fixtures__/server'; -import { AuthenticationResult } from '../authentication/authentication_result'; -import { authenticateFactory } from '../auth_redirect'; +import { AuthenticationResult } from './authentication_result'; +import { authenticateFactory } from './auth_redirect'; describe('lib/auth_redirect', function () { let authenticate; diff --git a/x-pack/plugins/security/server/authentication/auth_redirect.ts b/x-pack/plugins/security/server/authentication/auth_redirect.ts index cbcd5ecaeb4790..b82fcab9e823c5 100644 --- a/x-pack/plugins/security/server/authentication/auth_redirect.ts +++ b/x-pack/plugins/security/server/authentication/auth_redirect.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { wrapError } from './errors'; +import { wrapError } from '../errors'; /** * Creates a hapi authenticate function that conditionally redirects diff --git a/x-pack/plugins/security/server/authentication/authentication_result.test.ts b/x-pack/plugins/security/server/authentication/authentication_result.test.ts index b226ddd7d8b9a0..dc9ec428723c5b 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.test.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { AuthenticatedUser } from '../../../common/model'; +import { AuthenticatedUser } from '../../common/model'; import { AuthenticationResult } from './authentication_result'; describe('AuthenticationResult', () => { diff --git a/x-pack/plugins/security/server/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts index be443462688be9..e3227399026514 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.ts @@ -7,7 +7,7 @@ /** * Represents status that `AuthenticationResult` can be in. */ -import { AuthenticatedUser } from '../../../common/model'; +import { AuthenticatedUser } from '../../common/model'; import { getErrorStatusCode } from '../errors'; enum AuthenticationResultStatus { diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 622d84dbc543a6..7c5a76dbc1d765 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -15,7 +15,7 @@ import { DeauthenticationResult } from './deauthentication_result'; import { Session } from './session'; import { LoginAttempt } from './login_attempt'; import { initAuthenticator } from './authenticator'; -import * as ClientShield from '../../../../../server/lib/get_client_shield'; +import * as ClientShield from '../../../../legacy/server/lib/get_client_shield'; describe('Authenticator', () => { const sandbox = sinon.createSandbox(); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 177c76b56ff635..4a3c6575ace4e6 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -5,7 +5,7 @@ */ import { Legacy } from 'kibana'; -import { getClient } from '../../../../../server/lib/get_client_shield'; +import { getClient } from '../../../../legacy/server/lib/get_client_shield'; import { getErrorStatusCode } from '../errors'; import { AuthenticationProviderOptions, diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 1a70fdf879da58..7903822082880e 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -4,5 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { canRedirectRequest } from './can_redirect_request'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; +export { BasicCredentials } from './providers'; diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 358c0322bc3ff4..074bc5cc7ee73f 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -7,7 +7,7 @@ import Boom from 'boom'; import type from 'type-detect'; import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; +import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { Tokens, TokenPair } from '../tokens'; diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index c6209abc26bb97..7017a15b5e11be 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -6,8 +6,8 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; -import { AuthenticatedUser } from '../../../../common/model'; +import { canRedirectRequest } from '../can_redirect_request'; +import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { Tokens, TokenPair } from '../tokens'; diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 73e757b71d51d6..8263681027c9cc 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -6,7 +6,7 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; +import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { Tokens, TokenPair } from '../tokens'; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index c5a96bc8253f1a..8a796641dda717 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; -import { validateConfig } from '../validate_config'; +import { validateConfig } from './config'; describe('Validate config', function () { let config; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts new file mode 100644 index 00000000000000..5ba1cda9cd97df --- /dev/null +++ b/x-pack/plugins/security/server/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// These exports are part of public Security plugin contract, any change in signature of exported +// functions or removal of exports should be considered as a breaking change. +export { wrapError } from './errors'; +export { + canRedirectRequest, + AuthenticationResult, + BasicCredentials, + DeauthenticationResult, +} from './authentication'; From b3abda440699361450d0caf913e37a3ce74445ed Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 3 Jul 2019 16:45:25 +0200 Subject: [PATCH 04/13] Migrate to the New Platform. --- src/test_utils/kbn_server.ts | 2 +- .../security/__snapshots__/index.test.js.snap | 54 - x-pack/legacy/plugins/security/index.d.ts | 4 - x-pack/legacy/plugins/security/index.js | 85 +- x-pack/legacy/plugins/security/index.test.js | 116 --- .../lib/__tests__/__fixtures__/request.ts | 14 +- .../plugins/security/server/lib/get_user.ts | 20 - .../routes/api/v1/__tests__/authenticate.js | 85 +- .../server/routes/api/v1/__tests__/users.js | 30 +- .../server/routes/api/v1/authenticate.js | 49 +- .../security/server/routes/api/v1/users.js | 15 +- .../server/routes/views/logged_out.js | 3 +- .../security/server/routes/views/login.js | 3 +- .../lib/get_spaces_usage_collector.test.ts | 3 - .../lib/__tests__/replace_injected_vars.js | 25 +- .../server/lib/replace_injected_vars.js | 5 +- x-pack/package.json | 2 +- .../common/model/authenticated_user.mock.ts | 20 + .../security/server/__fixtures__/index.ts | 7 + .../security/server/__fixtures__/request.ts | 37 + .../authentication/auth_redirect.test.ts | 154 --- .../server/authentication/auth_redirect.ts | 63 -- .../authentication_result.test.ts | 110 +- .../authentication/authentication_result.ts | 28 +- .../authentication/authenticator.test.ts | 840 ++++++++------- .../server/authentication/authenticator.ts | 368 ++++--- .../can_redirect_request.test.ts | 11 +- .../authentication/can_redirect_request.ts | 12 +- .../server/authentication/index.test.ts | 297 ++++++ .../security/server/authentication/index.ts | 139 +++ .../authentication/login_attempt.test.ts | 38 - .../server/authentication/login_attempt.ts | 42 - .../authentication/providers/base.mock.ts | 34 +- .../server/authentication/providers/base.ts | 70 +- .../authentication/providers/basic.test.ts | 171 +-- .../server/authentication/providers/basic.ts | 171 ++- .../server/authentication/providers/index.ts | 4 +- .../authentication/providers/kerberos.test.ts | 273 ++--- .../authentication/providers/kerberos.ts | 158 +-- .../authentication/providers/oidc.test.ts | 477 ++++----- .../server/authentication/providers/oidc.ts | 235 ++--- .../authentication/providers/saml.test.ts | 983 ++++++++++-------- .../server/authentication/providers/saml.ts | 296 +++--- .../authentication/providers/token.test.ts | 403 ++++--- .../server/authentication/providers/token.ts | 225 ++-- .../server/authentication/session.test.ts | 191 ---- .../security/server/authentication/session.ts | 163 --- .../server/authentication/tokens.test.ts | 171 +-- .../security/server/authentication/tokens.ts | 56 +- x-pack/plugins/security/server/config.test.ts | 281 ++++- x-pack/plugins/security/server/config.ts | 104 +- x-pack/plugins/security/server/index.ts | 14 +- x-pack/plugins/security/server/plugin.test.ts | 87 ++ x-pack/plugins/security/server/plugin.ts | 121 +++ 54 files changed, 3882 insertions(+), 3487 deletions(-) delete mode 100644 x-pack/legacy/plugins/security/__snapshots__/index.test.js.snap delete mode 100644 x-pack/legacy/plugins/security/index.test.js delete mode 100644 x-pack/legacy/plugins/security/server/lib/get_user.ts create mode 100644 x-pack/plugins/security/common/model/authenticated_user.mock.ts create mode 100644 x-pack/plugins/security/server/__fixtures__/index.ts create mode 100644 x-pack/plugins/security/server/__fixtures__/request.ts delete mode 100644 x-pack/plugins/security/server/authentication/auth_redirect.test.ts delete mode 100644 x-pack/plugins/security/server/authentication/auth_redirect.ts create mode 100644 x-pack/plugins/security/server/authentication/index.test.ts delete mode 100644 x-pack/plugins/security/server/authentication/login_attempt.test.ts delete mode 100644 x-pack/plugins/security/server/authentication/login_attempt.ts delete mode 100644 x-pack/plugins/security/server/authentication/session.test.ts delete mode 100644 x-pack/plugins/security/server/authentication/session.ts create mode 100644 x-pack/plugins/security/server/plugin.test.ts create mode 100644 x-pack/plugins/security/server/plugin.ts diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts index e2d1cfd93464c5..2c20b39edc5fec 100644 --- a/src/test_utils/kbn_server.ts +++ b/src/test_utils/kbn_server.ts @@ -75,7 +75,7 @@ export function createRootWithSettings( repl: false, basePath: false, optimize: false, - oss: false, + oss: true, ...cliArgs, }, isDevClusterMaster: false, diff --git a/x-pack/legacy/plugins/security/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/security/__snapshots__/index.test.js.snap deleted file mode 100644 index 9a32c69743fab6..00000000000000 --- a/x-pack/legacy/plugins/security/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`config schema authc oidc realm realm is not allowed when authc.providers is "['basic']" 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is not allowed]]]`; - -exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is required]]]`; - -exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is required]]]`; - -exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc']" and realm is unspecified 2`] = `[ValidationError: child "authc" fails because [child "oidc" fails because [child "realm" fails because ["realm" is required]]]]`; - -exports[`config schema with context {"dist":false} produces correct config 1`] = ` -Object { - "audit": Object { - "enabled": false, - }, - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "authorization": Object { - "legacyFallback": Object { - "enabled": true, - }, - }, - "cookieName": "sid", - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "secureCookies": false, - "sessionTimeout": null, -} -`; - -exports[`config schema with context {"dist":true} produces correct config 1`] = ` -Object { - "audit": Object { - "enabled": false, - }, - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "authorization": Object { - "legacyFallback": Object { - "enabled": true, - }, - }, - "cookieName": "sid", - "enabled": true, - "secureCookies": false, - "sessionTimeout": null, -} -`; diff --git a/x-pack/legacy/plugins/security/index.d.ts b/x-pack/legacy/plugins/security/index.d.ts index bc8a09d3598bf3..a0d18dd3cbb99c 100644 --- a/x-pack/legacy/plugins/security/index.d.ts +++ b/x-pack/legacy/plugins/security/index.d.ts @@ -6,7 +6,6 @@ import { Legacy } from 'kibana'; import { AuthenticatedUser } from './common/model'; -import { AuthenticationResult, DeauthenticationResult } from './server/lib/authentication'; import { AuthorizationService } from './server/lib/authorization/service'; /** @@ -14,8 +13,5 @@ import { AuthorizationService } from './server/lib/authorization/service'; */ export interface SecurityPlugin { authorization: Readonly; - authenticate: (request: Legacy.Request) => Promise; - deauthenticate: (request: Legacy.Request) => Promise; getUser: (request: Legacy.Request) => Promise; - isAuthenticated: (request: Legacy.Request) => Promise; } diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 250c8729b5466c..8481c42dab34c6 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -5,7 +5,6 @@ */ import { resolve } from 'path'; -import { getUserProvider } from './server/lib/get_user'; import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; import { initUsersApi } from './server/routes/api/v1/users'; import { initExternalRolesApi } from './server/routes/api/external/roles'; @@ -15,10 +14,7 @@ import { initOverwrittenSessionView } from './server/routes/views/overwritten_se import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; import { initLoggedOutView } from './server/routes/views/logged_out'; -import { validateConfig } from './server/lib/validate_config'; -import { authenticateFactory } from './server/lib/auth_redirect'; import { checkLicense } from './server/lib/check_license'; -import { initAuthenticator } from './server/lib/authentication/authenticator'; import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; import { @@ -33,7 +29,9 @@ import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status import { SecureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/secure_saved_objects_client_wrapper'; import { deepFreeze } from './server/lib/deep_freeze'; import { createOptionalPlugin } from '../../server/lib/optional_plugin'; +import { KibanaRequest } from '../../../../src/core/server'; +let defaultVars; export const security = (kibana) => new kibana.Plugin({ id: 'security', configPrefix: 'xpack.security', @@ -41,23 +39,12 @@ export const security = (kibana) => new kibana.Plugin({ require: ['kibana', 'elasticsearch', 'xpack_main'], config(Joi) { - const providerOptionsSchema = (providerName, schema) => Joi.any() - .when('providers', { - is: Joi.array().items(Joi.string().valid(providerName).required(), Joi.string()), - then: schema, - otherwise: Joi.any().forbidden(), - }); - return Joi.object({ enabled: Joi.boolean().default(true), - cookieName: Joi.string().default('sid'), - encryptionKey: Joi.when(Joi.ref('$dist'), { - is: true, - then: Joi.string(), - otherwise: Joi.string().default('a'.repeat(32)), - }), - sessionTimeout: Joi.number().allow(null).default(null), - secureCookies: Joi.boolean().default(false), + cookieName: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + encryptionKey: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + sessionTimeout: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + secureCookies: Joi.any().description('This key is handled in the new platform security plugin ONLY'), authorization: Joi.object({ legacyFallback: Joi.object({ enabled: Joi.boolean().default(true) // deprecated @@ -66,11 +53,7 @@ export const security = (kibana) => new kibana.Plugin({ audit: Joi.object({ enabled: Joi.boolean().default(false) }).default(), - authc: Joi.object({ - providers: Joi.array().items(Joi.string()).default(['basic']), - oidc: providerOptionsSchema('oidc', Joi.object({ realm: Joi.string().required() }).required()), - saml: providerOptionsSchema('saml', Joi.object({ realm: Joi.string().required() }).required()), - }).default() + authc: Joi.any().description('This key is handled in the new platform security plugin ONLY') }).default(); }, @@ -111,15 +94,7 @@ export const security = (kibana) => new kibana.Plugin({ 'plugins/security/hacks/on_unauthorized_response' ], home: ['plugins/security/register_feature'], - injectDefaultVars: function (server) { - const config = server.config(); - - return { - secureCookies: config.get('xpack.security.secureCookies'), - sessionTimeout: config.get('xpack.security.sessionTimeout'), - enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), - }; - } + injectDefaultVars: () => defaultVars, }, async postInit(server) { @@ -137,28 +112,37 @@ export const security = (kibana) => new kibana.Plugin({ }, async init(server) { - const plugin = this; + const securityPlugin = this.kbnServer.newPlatform.setup.plugins.security; + if (!securityPlugin) { + throw new Error('New Platform XPack Security plugin is not available.'); + } - const config = server.config(); const xpackMainPlugin = server.plugins.xpack_main; const xpackInfo = xpackMainPlugin.info; + securityPlugin.registerLegacyAPI({ + xpackInfo, + isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind( + server.plugins.kibana.systemApi + ), + }); + const plugin = this; + const config = server.config(); const xpackInfoFeature = xpackInfo.feature(plugin.id); + // Config required for default injected vars is coming from new platform plugin and hence we can + // initialize these only within `init` function of the legacy plugin. + defaultVars = { + secureCookies: securityPlugin.config.secureCookies, + sessionTimeout: securityPlugin.config.sessionTimeout, + enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), + }; + // Register a function that is called whenever the xpack info changes, // to re-compute the license check results for this plugin xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense); - validateConfig(config, message => server.log(['security', 'warning'], message)); - - // Create a Hapi auth scheme that should be applied to each request. - server.auth.scheme('login', () => ({ authenticate: authenticateFactory(server) })); - - 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. - server.auth.default('session'); + server.expose({ getUser: request => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)) }); const { savedObjects } = server; @@ -202,19 +186,16 @@ export const security = (kibana) => new kibana.Plugin({ return client; }); - getUserProvider(server); - - await initAuthenticator(server); - initAuthenticateApi(server); + initAuthenticateApi(securityPlugin, server); initAPIAuthorization(server, authorization); initAppAuthorization(server, xpackMainPlugin, authorization); - initUsersApi(server); + initUsersApi(securityPlugin, server); initExternalRolesApi(server); initIndicesApi(server); initPrivilegesApi(server); - initLoginView(server, xpackMainPlugin); + initLoginView(securityPlugin, server, xpackMainPlugin); initLogoutView(server); - initLoggedOutView(server); + initLoggedOutView(securityPlugin, server); initOverwrittenSessionView(server); server.injectUiAppVars('login', () => { diff --git a/x-pack/legacy/plugins/security/index.test.js b/x-pack/legacy/plugins/security/index.test.js deleted file mode 100644 index 34f95fe798fe82..00000000000000 --- a/x-pack/legacy/plugins/security/index.test.js +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { security } from './index'; -import { getConfigSchema } from '../../../test_utils'; - -const describeWithContext = describe.each([[{ dist: false }], [{ dist: true }]]); - -describeWithContext('config schema with context %j', context => { - it('produces correct config', async () => { - const schema = await getConfigSchema(security); - await expect(schema.validate({}, { context })).resolves.toMatchSnapshot(); - }); -}); - -describe('config schema', () => { - describe('authc', () => { - describe('oidc', () => { - describe('realm', () => { - it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => { - const schema = await getConfigSchema(security); - expect(schema.validate({ authc: { providers: ['oidc'] } }).error).toMatchSnapshot(); - expect(schema.validate({ authc: { providers: ['oidc'], oidc: {} } }).error).toMatchSnapshot(); - }); - - it(`is valid when authc.providers is "['oidc']" and realm is specified`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { - providers: ['oidc'], - oidc: { - realm: 'realm-1', - }, - }, - }); - expect(validationResult.error).toBeNull(); - expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1'); - }); - - it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { providers: ['oidc', 'basic'] }, - }); - expect(validationResult.error).toMatchSnapshot(); - }); - - it(`is valid when authc.providers is "['oidc', 'basic']" and realm is specified`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { - providers: ['oidc', 'basic'], - oidc: { - realm: 'realm-1', - }, - }, - }); - expect(validationResult.error).toBeNull(); - expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1'); - }); - - it(`realm is not allowed when authc.providers is "['basic']"`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { - providers: ['basic'], - oidc: { - realm: 'realm-1', - }, - }, - }); - expect(validationResult.error).toMatchSnapshot(); - }); - }); - }); - - describe('saml', () => { - it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => { - const schema = await getConfigSchema(security); - - expect(schema.validate({ authc: { providers: ['saml'] } }).error).toMatchInlineSnapshot( - `[ValidationError: child "authc" fails because [child "saml" fails because ["saml" is required]]]` - ); - expect( - schema.validate({ authc: { providers: ['saml'], saml: {} } }).error - ).toMatchInlineSnapshot( - `[ValidationError: child "authc" fails because [child "saml" fails because [child "realm" fails because ["realm" is required]]]]` - ); - - const validationResult = schema.validate({ - authc: { providers: ['saml'], saml: { realm: 'realm-1' } }, - }); - - expect(validationResult.error).toBeNull(); - expect(validationResult.value.authc.saml.realm).toBe('realm-1'); - }); - - it('`realm` is not allowed if saml provider is not enabled', async () => { - const schema = await getConfigSchema(security); - expect( - schema.validate({ - authc: { - providers: ['basic'], - saml: { realm: 'realm-1' }, - }, - }).error - ).toMatchInlineSnapshot( - `[ValidationError: child "authc" fails because [child "saml" fails because ["saml" is not allowed]]]` - ); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts index 753325215d3770..c928a38d88ef3a 100644 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts +++ b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts @@ -5,9 +5,7 @@ */ import { Request } from 'hapi'; -import { stub } from 'sinon'; import url from 'url'; -import { LoginAttempt } from '../../authentication/login_attempt'; interface RequestFixtureOptions { headers?: Record; @@ -24,26 +22,18 @@ export function requestFixture({ auth, params, path = '/wat', - basePath = '', search = '', payload, }: RequestFixtureOptions = {}) { - const cookieAuth = { clear: stub(), set: stub() }; return ({ raw: { req: { headers } }, auth, headers, params, url: { path, search }, - cookieAuth, - getBasePath: () => basePath, - loginAttempt: stub().returns(new LoginAttempt()), query: search ? url.parse(search, true /* parseQueryString */).query : {}, payload, state: { user: 'these are the contents of the user client cookie' }, - } as any) as Request & { - cookieAuth: typeof cookieAuth; - loginAttempt: () => LoginAttempt; - getBasePath: () => string; - }; + route: { settings: {} }, + } as any) as Request; } diff --git a/x-pack/legacy/plugins/security/server/lib/get_user.ts b/x-pack/legacy/plugins/security/server/lib/get_user.ts deleted file mode 100644 index d0b3321444410f..00000000000000 --- a/x-pack/legacy/plugins/security/server/lib/get_user.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { getClient } from '../../../../server/lib/get_client_shield'; - -export function getUserProvider(server: any) { - const callWithRequest = getClient(server).callWithRequest; - - server.expose('getUser', async (request: Legacy.Request) => { - const xpackInfo = server.plugins.xpack_main.info; - if (xpackInfo && xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) { - return Promise.resolve(null); - } - return await callWithRequest(request, 'shield.authenticate'); - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js index 49bbdee39b917c..96b47d1407bf19 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js @@ -11,14 +11,15 @@ import sinon from 'sinon'; import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server'; import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request'; -import { AuthenticationResult } from '../../../../../server/lib/authentication/authentication_result'; -import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic'; +import { AuthenticationResult, DeauthenticationResult } from '../../../../../../../../plugins/security/server'; import { initAuthenticateApi } from '../authenticate'; -import { DeauthenticationResult } from '../../../../lib/authentication/deauthentication_result'; +import { KibanaRequest } from '../../../../../../../../../src/core/server'; describe('Authentication routes', () => { let serverStub; let hStub; + let loginStub; + let logoutStub; beforeEach(() => { serverStub = serverFixture(); @@ -28,14 +29,18 @@ describe('Authentication routes', () => { redirect: sinon.stub(), response: sinon.stub() }; + loginStub = sinon.stub(); + logoutStub = sinon.stub(); - initAuthenticateApi(serverStub); + initAuthenticateApi({ + authc: { login: loginStub, logout: logoutStub }, + config: { authc: { providers: ['basic'] } }, + }, serverStub); }); describe('login', () => { let loginRoute; let request; - let authenticateStub; beforeEach(() => { loginRoute = serverStub.route @@ -47,10 +52,6 @@ describe('Authentication routes', () => { headers: {}, payload: { username: 'user', password: 'password' } }); - - authenticateStub = serverStub.plugins.security.authenticate.withArgs( - sinon.match(BasicCredentials.decorateRequest(request, 'user', 'password')) - ); }); it('correctly defines route.', async () => { @@ -73,7 +74,7 @@ describe('Authentication routes', () => { it('returns 500 if authentication throws unhandled exception.', async () => { const unhandledException = new Error('Something went wrong.'); - authenticateStub.throws(unhandledException); + loginStub.throws(unhandledException); return loginRoute .handler(request, hStub) @@ -89,7 +90,7 @@ describe('Authentication routes', () => { it('returns 401 if authentication fails.', async () => { const failureReason = new Error('Something went wrong.'); - authenticateStub.returns(Promise.resolve(AuthenticationResult.failed(failureReason))); + loginStub.resolves(AuthenticationResult.failed(failureReason)); return loginRoute .handler(request, hStub) @@ -101,9 +102,7 @@ describe('Authentication routes', () => { }); it('returns 401 if authentication is not handled.', async () => { - authenticateStub.returns( - Promise.resolve(AuthenticationResult.notHandled()) - ); + loginStub.resolves(AuthenticationResult.notHandled()); return loginRoute .handler(request, hStub) @@ -117,14 +116,17 @@ describe('Authentication routes', () => { describe('authentication succeeds', () => { it(`returns user data`, async () => { - const user = { username: 'user' }; - authenticateStub.returns( - Promise.resolve(AuthenticationResult.succeeded(user)) - ); + loginStub.resolves(AuthenticationResult.succeeded({ username: 'user' })); await loginRoute.handler(request, hStub); sinon.assert.calledOnce(hStub.response); + sinon.assert.calledOnce(loginStub); + sinon.assert.calledWithExactly( + loginStub, + sinon.match.instanceOf(KibanaRequest), + { provider: 'basic', value: { username: 'user', password: 'password' } } + ); }); }); @@ -155,9 +157,7 @@ describe('Authentication routes', () => { const request = requestFixture(); const unhandledException = new Error('Something went wrong.'); - serverStub.plugins.security.deauthenticate - .withArgs(request) - .returns(Promise.reject(unhandledException)); + logoutStub.rejects(unhandledException); return logoutRoute .handler(request, hStub) @@ -167,19 +167,22 @@ describe('Authentication routes', () => { }); }); - it('returns 500 if authenticator fails to deauthenticate.', async () => { + it('returns 500 if authenticator fails to logout.', async () => { const request = requestFixture(); const failureReason = Boom.forbidden(); - serverStub.plugins.security.deauthenticate - .withArgs(request) - .returns(Promise.resolve(DeauthenticationResult.failed(failureReason))); + logoutStub.resolves(DeauthenticationResult.failed(failureReason)); return logoutRoute .handler(request, hStub) .catch((response) => { expect(response).to.be(Boom.boomify(failureReason)); sinon.assert.notCalled(hStub.redirect); + sinon.assert.calledOnce(logoutStub); + sinon.assert.calledWithExactly( + logoutStub, + sinon.match.instanceOf(KibanaRequest) + ); }); }); @@ -199,11 +202,7 @@ describe('Authentication routes', () => { it('redirects user to the URL returned by authenticator.', async () => { const request = requestFixture(); - serverStub.plugins.security.deauthenticate - .withArgs(request) - .returns( - Promise.resolve(DeauthenticationResult.redirectTo('https://custom.logout')) - ); + logoutStub.resolves(DeauthenticationResult.redirectTo('https://custom.logout')); await logoutRoute.handler(request, hStub); @@ -214,9 +213,7 @@ describe('Authentication routes', () => { it('redirects user to the base path if deauthentication succeeds.', async () => { const request = requestFixture(); - serverStub.plugins.security.deauthenticate - .withArgs(request) - .returns(Promise.resolve(DeauthenticationResult.succeeded())); + logoutStub.resolves(DeauthenticationResult.succeeded()); await logoutRoute.handler(request, hStub); @@ -227,9 +224,7 @@ describe('Authentication routes', () => { it('redirects user to the base path if deauthentication is not handled.', async () => { const request = requestFixture(); - serverStub.plugins.security.deauthenticate - .withArgs(request) - .returns(Promise.resolve(DeauthenticationResult.notHandled())); + logoutStub.resolves(DeauthenticationResult.notHandled()); await logoutRoute.handler(request, hStub); @@ -293,7 +288,7 @@ describe('Authentication routes', () => { it('returns 500 if authentication throws unhandled exception.', async () => { const unhandledException = new Error('Something went wrong.'); - serverStub.plugins.security.authenticate.throws(unhandledException); + loginStub.throws(unhandledException); const response = await samlAcsRoute.handler(request, hStub); @@ -308,9 +303,7 @@ describe('Authentication routes', () => { it('returns 401 if authentication fails.', async () => { const failureReason = new Error('Something went wrong.'); - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.failed(failureReason)) - ); + loginStub.resolves(AuthenticationResult.failed(failureReason)); const response = await samlAcsRoute.handler(request, hStub); @@ -321,9 +314,7 @@ describe('Authentication routes', () => { }); it('returns 401 if authentication is not handled.', async () => { - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.notHandled()) - ); + loginStub.resolves(AuthenticationResult.notHandled()); const response = await samlAcsRoute.handler(request, hStub); @@ -334,9 +325,7 @@ describe('Authentication routes', () => { }); it('returns 401 if authentication completes with unexpected result.', async () => { - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.succeeded({})) - ); + loginStub.resolves(AuthenticationResult.succeeded({})); const response = await samlAcsRoute.handler(request, hStub); @@ -347,9 +336,7 @@ describe('Authentication routes', () => { }); it('redirects if required by the authentication process.', async () => { - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.redirectTo('http://redirect-to/path')) - ); + loginStub.resolves(AuthenticationResult.redirectTo('http://redirect-to/path')); await samlAcsRoute.handler(request, hStub); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js index 05c5cad41e2c36..69ebc526fd8982 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js @@ -10,26 +10,28 @@ import sinon from 'sinon'; import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server'; import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request'; -import { AuthenticationResult } from '../../../../../server/lib/authentication/authentication_result'; -import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic'; +import { AuthenticationResult, BasicCredentials } from '../../../../../../../../plugins/security/server'; import { initUsersApi } from '../users'; import * as ClientShield from '../../../../../../../server/lib/get_client_shield'; +import { KibanaRequest } from '../../../../../../../../../src/core/server'; describe('User routes', () => { const sandbox = sinon.createSandbox(); let clusterStub; let serverStub; + let loginStub; beforeEach(() => { serverStub = serverFixture(); + loginStub = sinon.stub(); // Cluster is returned by `getClient` function that is wrapped into `once` making cluster // a static singleton, so we should use sandbox to set/reset its behavior between tests. clusterStub = sinon.stub({ callWithRequest() {} }); sandbox.stub(ClientShield, 'getClient').returns(clusterStub); - initUsersApi(serverStub); + initUsersApi({ authc: { login: loginStub }, config: { authc: { providers: ['basic'] } } }, serverStub); }); afterEach(() => sandbox.restore()); @@ -98,13 +100,12 @@ describe('User routes', () => { it('returns 401 if user can authenticate with new password.', async () => { getUserStub.returns(Promise.resolve({})); - serverStub.plugins.security.authenticate + loginStub .withArgs( - sinon.match(BasicCredentials.decorateRequest(request, 'user', 'new-password')) + sinon.match.instanceOf(KibanaRequest), + { provider: 'basic', value: { username: 'user', password: 'new-password' } } ) - .returns( - Promise.resolve(AuthenticationResult.failed(new Error('Something went wrong.'))) - ); + .resolves(AuthenticationResult.failed(new Error('Something went wrong.'))); return changePasswordRoute .handler(request) @@ -150,13 +151,12 @@ describe('User routes', () => { it('successfully changes own password if provided old password is correct.', async () => { getUserStub.returns(Promise.resolve({})); - serverStub.plugins.security.authenticate + loginStub .withArgs( - sinon.match(BasicCredentials.decorateRequest(request, 'user', 'new-password')) + sinon.match.instanceOf(KibanaRequest), + { provider: 'basic', value: { username: 'user', password: 'new-password' } } ) - .returns( - Promise.resolve(AuthenticationResult.succeeded({})) - ); + .resolves(AuthenticationResult.succeeded({})); const hResponseStub = { code: sinon.stub() }; const hStub = { response: sinon.stub().returns(hResponseStub) }; @@ -190,7 +190,7 @@ describe('User routes', () => { .handler(request) .catch((response) => { sinon.assert.notCalled(serverStub.plugins.security.getUser); - sinon.assert.notCalled(serverStub.plugins.security.authenticate); + sinon.assert.notCalled(loginStub); expect(response.isBoom).to.be(true); expect(response.output.payload).to.eql({ @@ -208,7 +208,7 @@ describe('User routes', () => { await changePasswordRoute.handler(request, hStub); sinon.assert.notCalled(serverStub.plugins.security.getUser); - sinon.assert.notCalled(serverStub.plugins.security.authenticate); + sinon.assert.notCalled(loginStub); sinon.assert.calledOnce(clusterStub.callWithRequest); sinon.assert.calledWithExactly( diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js index 8075b2e46fcd26..66d099d1c5f731 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js @@ -6,9 +6,11 @@ import Boom from 'boom'; import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; import { canRedirectRequest, wrapError } from '../../../../../../../plugins/security/server'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; -export function initAuthenticateApi(server) { +export function initAuthenticateApi({ authc: { login, logout }, config }, server) { server.route({ method: 'POST', @@ -29,8 +31,14 @@ export function initAuthenticateApi(server) { const { username, password } = request.payload; try { - request.loginAttempt().setCredentials(username, password); - const authenticationResult = await server.plugins.security.authenticate(request); + // We should prefer `token` over `basic` if possible. + const providerToLoginWith = config.authc.providers.includes('token') + ? 'token' + : 'basic'; + const authenticationResult = await login(KibanaRequest.from(request), { + provider: providerToLoginWith, + value: { username, password } + }); if (!authenticationResult.succeeded()) { throw Boom.unauthorized(authenticationResult.error); @@ -58,7 +66,11 @@ export function initAuthenticateApi(server) { async handler(request, h) { try { // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. - const authenticationResult = await server.plugins.security.authenticate(request); + const authenticationResult = await login(KibanaRequest.from(request), { + provider: 'saml', + value: { samlResponse: request.payload.SAMLResponse } + }); + if (authenticationResult.redirected()) { return h.redirect(authenticationResult.redirectURL); } @@ -93,7 +105,24 @@ export function initAuthenticateApi(server) { try { // We handle the fact that the user might get redirected to Kibana while already having an session // Return an error notifying the user they are already logged in. - const authenticationResult = await server.plugins.security.authenticate(request); + const authenticationResult = await login(KibanaRequest.from(request), { + provider: 'oidc', + // Checks if the request object represents an HTTP request regarding authentication with OpenID Connect. + // This can be + // - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication + // - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication + // - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from + // an OpenID Connect Provider + // - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from + // an OpenID Connect Provider + value: { + code: request.query && request.query.code, + iss: (request.query && request.query.iss) || (request.payload && request.payload.iss), + loginHint: + (request.query && request.query.login_hint) || + (request.payload && request.payload.login_hint), + }, + }); if (authenticationResult.succeeded()) { return Boom.forbidden( 'Sorry, you already have an active Kibana session. ' + @@ -119,12 +148,18 @@ export function initAuthenticateApi(server) { auth: false }, async handler(request, h) { - if (!canRedirectRequest(request)) { + if (!canRedirectRequest(KibanaRequest.from(request))) { throw Boom.badRequest('Client should be able to process redirect response.'); } try { - const deauthenticationResult = await server.plugins.security.deauthenticate(request); + const deauthenticationResult = await logout( + // Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any + // set of query string parameters (e.g. SAML/OIDC logout request parameters). + KibanaRequest.from(request, { + query: schema.object({}, { allowUnknowns: true }), + }) + ); if (deauthenticationResult.failed()) { throw wrapError(deauthenticationResult.error); } diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js index 54aed121ca20d9..09ade22d61456d 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js @@ -9,10 +9,11 @@ import Boom from 'boom'; import Joi from 'joi'; import { getClient } from '../../../../../../server/lib/get_client_shield'; import { userSchema } from '../../../lib/user_schema'; -import { BasicCredentials, wrapError } from '../../../../../../../plugins/security/server'; import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; +import { BasicCredentials, wrapError } from '../../../../../../../plugins/security/server'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; -export function initUsersApi(server) { +export function initUsersApi({ authc: { login }, config }, server) { const callWithRequest = getClient(server).callWithRequest; const routePreCheckLicenseFn = routePreCheckLicense(server); @@ -104,8 +105,14 @@ export function initUsersApi(server) { // Now we authenticate user with the new password again updating current session if any. if (isCurrentUser) { - request.loginAttempt().setCredentials(username, newPassword); - const authenticationResult = await server.plugins.security.authenticate(request); + // We should prefer `token` over `basic` if possible. + const providerToLoginWith = config.authc.providers.includes('token') + ? 'token' + : 'basic'; + const authenticationResult = await login(KibanaRequest.from(request), { + provider: providerToLoginWith, + value: { username, password: newPassword } + }); if (!authenticationResult.succeeded()) { throw Boom.unauthorized((authenticationResult.error)); diff --git a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js index 7a0a1da9ed0d3d..51867631b57bee 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js +++ b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export function initLoggedOutView(server) { +export function initLoggedOutView({ config: { cookieName } }, server) { const config = server.config(); const loggedOut = server.getHiddenUiAppById('logged_out'); - const cookieName = config.get('xpack.security.cookieName'); server.route({ method: 'GET', diff --git a/x-pack/legacy/plugins/security/server/routes/views/login.js b/x-pack/legacy/plugins/security/server/routes/views/login.js index 95c0c56ed6ad56..f7e7f2933efcc6 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/login.js +++ b/x-pack/legacy/plugins/security/server/routes/views/login.js @@ -8,9 +8,8 @@ import { get } from 'lodash'; import { parseNext } from '../../lib/parse_next'; -export function initLoginView(server, xpackMainPlugin) { +export function initLoginView({ config: { cookieName } }, server, xpackMainPlugin) { const config = server.config(); - const cookieName = config.get('xpack.security.cookieName'); const login = server.getHiddenUiAppById('login'); function shouldShowLogin() { diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts b/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts index 0f7bf6be641564..9096a19a24d06e 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts @@ -22,9 +22,6 @@ function getServerMock(customization?: any) { const getLicenseCheckResults = jest.fn().mockReturnValue({}); const defaultServerMock = { plugins: { - security: { - isAuthenticated: jest.fn().mockReturnValue(true), - }, xpack_main: { info: { isAvailable: jest.fn().mockReturnValue(true), diff --git a/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js b/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js index 53e83ce46f4424..71d6000329816b 100644 --- a/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js +++ b/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js @@ -8,17 +8,19 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; import { replaceInjectedVars } from '../replace_injected_vars'; +import { KibanaRequest } from '../../../../../../../src/core/server'; const buildRequest = (telemetryOptedIn = null, path = '/app/kibana') => { const get = sinon.stub(); if (telemetryOptedIn === null) { - get.withArgs('telemetry', 'telemetry').returns(Promise.reject(new Error('not found exception'))); + get.withArgs('telemetry', 'telemetry').rejects(new Error('not found exception')); } else { - get.withArgs('telemetry', 'telemetry').returns(Promise.resolve({ attributes: { enabled: telemetryOptedIn } })); + get.withArgs('telemetry', 'telemetry').resolves({ attributes: { enabled: telemetryOptedIn } }); } return { path, + route: { settings: {} }, getSavedObjectsClient: () => { return { get, @@ -49,8 +51,11 @@ describe('replaceInjectedVars uiExport', () => { }, }); - sinon.assert.calledOnce(server.plugins.security.isAuthenticated); - expect(server.plugins.security.isAuthenticated.firstCall.args[0]).to.be(request); + sinon.assert.calledOnce(server.newPlatform.setup.plugins.security.authc.isAuthenticated); + sinon.assert.calledWithExactly( + server.newPlatform.setup.plugins.security.authc.isAuthenticated, + sinon.match.instanceOf(KibanaRequest) + ); }); it('sends the xpack info if security plugin is disabled', async () => { @@ -58,6 +63,7 @@ describe('replaceInjectedVars uiExport', () => { const request = buildRequest(); const server = mockServer(); delete server.plugins.security; + delete server.newPlatform.setup.plugins.security; const newVars = await replaceInjectedVars(originalInjectedVars, request, server); expect(newVars).to.eql({ @@ -137,7 +143,7 @@ describe('replaceInjectedVars uiExport', () => { const originalInjectedVars = { a: 1 }; const request = buildRequest(); const server = mockServer(); - server.plugins.security.isAuthenticated.returns(false); + server.newPlatform.setup.plugins.security.authc.isAuthenticated.returns(false); const newVars = await replaceInjectedVars(originalInjectedVars, request, server); expect(newVars).to.eql(originalInjectedVars); @@ -191,10 +197,13 @@ describe('replaceInjectedVars uiExport', () => { function mockServer() { const getLicenseCheckResults = sinon.stub().returns({}); return { + newPlatform: { + setup: { + plugins: { security: { authc: { isAuthenticated: sinon.stub().returns(true) } } } + } + }, plugins: { - security: { - isAuthenticated: sinon.stub().returns(true) - }, + security: {}, xpack_main: { getFeatures: () => [{ id: 'mockFeature', diff --git a/x-pack/legacy/plugins/xpack_main/server/lib/replace_injected_vars.js b/x-pack/legacy/plugins/xpack_main/server/lib/replace_injected_vars.js index 027dc9d2390b49..9def7da5e7e4f6 100644 --- a/x-pack/legacy/plugins/xpack_main/server/lib/replace_injected_vars.js +++ b/x-pack/legacy/plugins/xpack_main/server/lib/replace_injected_vars.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KibanaRequest } from '../../../../../../src/core/server'; import { getTelemetryOptIn } from '../../../telemetry/server'; export async function replaceInjectedVars(originalInjectedVars, request, server) { @@ -16,7 +17,7 @@ export async function replaceInjectedVars(originalInjectedVars, request, server) }); // security feature is disabled - if (!server.plugins.security) { + if (!server.plugins.security || !server.newPlatform.setup.plugins.security) { return await withXpackInfo(); } @@ -26,7 +27,7 @@ export async function replaceInjectedVars(originalInjectedVars, request, server) } // request is not authenticated - if (!await server.plugins.security.isAuthenticated(request)) { + if (!await server.newPlatform.setup.plugins.security.authc.isAuthenticated(KibanaRequest.from(request))) { return originalInjectedVars; } diff --git a/x-pack/package.json b/x-pack/package.json index bf8135502ebee1..d864417041a301 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -179,6 +179,7 @@ "@elastic/numeral": "2.3.3", "@elastic/request-crypto": "^1.0.2", "@kbn/babel-preset": "1.0.0", + "@kbn/config-schema": "1.0.0", "@kbn/elastic-idx": "1.0.0", "@kbn/es-query": "1.0.0", "@kbn/i18n": "1.0.0", @@ -240,7 +241,6 @@ "graphql-tools": "^3.0.2", "h2o2": "^8.1.2", "handlebars": "^4.1.2", - "hapi-auth-cookie": "^9.0.0", "history": "4.9.0", "history-extra": "^5.0.1", "humps": "2.0.1", diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts new file mode 100644 index 00000000000000..3a93efc57b5f60 --- /dev/null +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AuthenticatedUser } from './authenticated_user'; + +export function mockAuthenticatedUser(user: Partial = {}) { + return { + username: 'user', + email: 'email', + full_name: 'full name', + roles: ['user-role'], + enabled: true, + authentication_realm: { name: 'native1', type: 'native' }, + lookup_realm: { name: 'native1', type: 'native' }, + ...user, + }; +} diff --git a/x-pack/plugins/security/server/__fixtures__/index.ts b/x-pack/plugins/security/server/__fixtures__/index.ts new file mode 100644 index 00000000000000..c8f0d6dee273f3 --- /dev/null +++ b/x-pack/plugins/security/server/__fixtures__/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { requestFixture } from './request'; diff --git a/x-pack/plugins/security/server/__fixtures__/request.ts b/x-pack/plugins/security/server/__fixtures__/request.ts new file mode 100644 index 00000000000000..869595d9aa4e1c --- /dev/null +++ b/x-pack/plugins/security/server/__fixtures__/request.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import url from 'url'; +import { schema } from '@kbn/config-schema'; +import { KibanaRequest } from '../../../../../src/core/server'; + +interface RequestFixtureOptions { + headers?: Record; + params?: Record; + path?: string; + search?: string; + payload?: unknown; +} + +export function requestFixture({ + headers = { accept: 'something/html' }, + params, + path = '/wat', + search = '', + payload, +}: RequestFixtureOptions = {}) { + return KibanaRequest.from( + { + headers, + params, + url: { path, search }, + query: search ? url.parse(search, true /* parseQueryString */).query : {}, + payload, + route: { settings: {} }, + } as any, + { query: schema.object({}, { allowUnknowns: true }) } + ); +} diff --git a/x-pack/plugins/security/server/authentication/auth_redirect.test.ts b/x-pack/plugins/security/server/authentication/auth_redirect.test.ts deleted file mode 100644 index 7d92a15532da29..00000000000000 --- a/x-pack/plugins/security/server/authentication/auth_redirect.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -import { hFixture } from './__fixtures__/h'; -import { requestFixture } from './__fixtures__/request'; -import { serverFixture } from './__fixtures__/server'; - -import { AuthenticationResult } from './authentication_result'; -import { authenticateFactory } from './auth_redirect'; - -describe('lib/auth_redirect', function () { - let authenticate; - let request; - let h; - let err; - let credentials; - let server; - - beforeEach(() => { - request = requestFixture(); - h = hFixture(); - err = new Error(); - credentials = {}; - server = serverFixture(); - - server.plugins.xpack_main.info - .isAvailable.returns(true); - server.plugins.xpack_main.info - .feature.returns({ isEnabled: sinon.stub().returns(true) }); - - authenticate = authenticateFactory(server); - }); - - it('invokes `authenticate` with request', async () => { - server.plugins.security.authenticate.withArgs(request).returns( - Promise.resolve(AuthenticationResult.succeeded(credentials)) - ); - - await authenticate(request, h); - - sinon.assert.calledWithExactly(server.plugins.security.authenticate, request); - }); - - it('continues request with credentials on success', async () => { - server.plugins.security.authenticate.withArgs(request).returns( - Promise.resolve(AuthenticationResult.succeeded(credentials)) - ); - - await authenticate(request, h); - - sinon.assert.calledWith(h.authenticated, { credentials }); - sinon.assert.notCalled(h.redirect); - }); - - it('redirects user if redirection is requested by the authenticator', async () => { - server.plugins.security.authenticate.withArgs(request).returns( - Promise.resolve(AuthenticationResult.redirectTo('/some/url')) - ); - - await authenticate(request, h); - - sinon.assert.calledWithExactly(h.redirect, '/some/url'); - sinon.assert.called(h.takeover); - sinon.assert.notCalled(h.authenticated); - }); - - it('returns `Internal Server Error` when `authenticate` throws unhandled exception', async () => { - server.plugins.security.authenticate - .withArgs(request) - .returns(Promise.reject(err)); - - const response = await authenticate(request, h); - - sinon.assert.calledWithExactly(server.log, ['error', 'authentication'], sinon.match.same(err)); - expect(response.isBoom).to.be(true); - expect(response.output.statusCode).to.be(500); - sinon.assert.notCalled(h.redirect); - sinon.assert.notCalled(h.authenticated); - }); - - it('returns wrapped original error when `authenticate` fails to authenticate user', async () => { - const esError = Boom.badRequest('some message'); - server.plugins.security.authenticate.withArgs(request).returns( - Promise.resolve(AuthenticationResult.failed(esError)) - ); - - const response = await authenticate(request, h); - - sinon.assert.calledWithExactly( - server.log, - ['info', 'authentication'], - 'Authentication attempt failed: some message' - ); - expect(response).to.eql(esError); - sinon.assert.notCalled(h.redirect); - sinon.assert.notCalled(h.authenticated); - }); - - it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => { - const originalEsError = Boom.unauthorized('some message'); - originalEsError.output.headers['WWW-Authenticate'] = [ - 'Basic realm="Access to prod", charset="UTF-8"', - 'Basic', - 'Negotiate' - ]; - - server.plugins.security.authenticate.withArgs(request).resolves( - AuthenticationResult.failed(originalEsError, ['Negotiate']) - ); - - const response = await authenticate(request, h); - - sinon.assert.calledWithExactly( - server.log, - ['info', 'authentication'], - 'Authentication attempt failed: some message' - ); - expect(response.message).to.eql(originalEsError.message); - expect(response.output.headers).to.eql({ 'WWW-Authenticate': ['Negotiate'] }); - sinon.assert.notCalled(h.redirect); - sinon.assert.notCalled(h.authenticated); - }); - - it('returns `unauthorized` when authentication can not be handled', async () => { - server.plugins.security.authenticate.withArgs(request).returns( - Promise.resolve(AuthenticationResult.notHandled()) - ); - - const response = await authenticate(request, h); - - expect(response.isBoom).to.be(true); - expect(response.message).to.be('Unauthorized'); - expect(response.output.statusCode).to.be(401); - sinon.assert.notCalled(h.redirect); - sinon.assert.notCalled(h.authenticated); - }); - - it('replies with no credentials when security is disabled in elasticsearch', async () => { - server.plugins.xpack_main.info.feature.returns({ isEnabled: sinon.stub().returns(false) }); - - await authenticate(request, h); - - sinon.assert.calledWith(h.authenticated, { credentials: {} }); - sinon.assert.notCalled(h.redirect); - }); - -}); diff --git a/x-pack/plugins/security/server/authentication/auth_redirect.ts b/x-pack/plugins/security/server/authentication/auth_redirect.ts deleted file mode 100644 index b82fcab9e823c5..00000000000000 --- a/x-pack/plugins/security/server/authentication/auth_redirect.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import { wrapError } from '../errors'; - -/** - * Creates a hapi authenticate function that conditionally redirects - * on auth failure. - * @param {Hapi.Server} server HapiJS Server instance. - * @returns {Function} Authentication function that will be called by Hapi for every - * request that needs to be authenticated. - */ -export function authenticateFactory(server) { - return async function authenticate(request, h) { - // If security is disabled continue with no user credentials - // and delete the client cookie as well. - const xpackInfo = server.plugins.xpack_main.info; - if (xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) { - return h.authenticated({ credentials: {} }); - } - - let authenticationResult; - try { - authenticationResult = await server.plugins.security.authenticate(request); - } catch (err) { - server.log(['error', 'authentication'], err); - return wrapError(err); - } - - if (authenticationResult.succeeded()) { - return h.authenticated({ credentials: authenticationResult.user }); - } - - if (authenticationResult.redirected()) { - // Some authentication mechanisms may require user to be redirected to another location to - // initiate or complete authentication flow. It can be Kibana own login page for basic - // authentication (username and password) or arbitrary external page managed by 3rd party - // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who - // decides what location user should be redirected to. - return h.redirect(authenticationResult.redirectURL).takeover(); - } - - if (authenticationResult.failed()) { - server.log( - ['info', 'authentication'], - `Authentication attempt failed: ${authenticationResult.error.message}` - ); - - const error = wrapError(authenticationResult.error); - if (authenticationResult.challenges) { - error.output.headers['WWW-Authenticate'] = authenticationResult.challenges; - } - - return error; - } - - return Boom.unauthorized(); - }; -} diff --git a/x-pack/plugins/security/server/authentication/authentication_result.test.ts b/x-pack/plugins/security/server/authentication/authentication_result.test.ts index dc9ec428723c5b..a6db5261785663 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.test.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { AuthenticatedUser } from '../../common/model'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { AuthenticationResult } from './authentication_result'; describe('AuthenticationResult', () => { @@ -21,6 +21,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); }); @@ -44,6 +45,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.error).toBe(failureReason); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); @@ -60,6 +62,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.error).toBe(failureReason); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); @@ -77,8 +80,8 @@ describe('AuthenticationResult', () => { ); }); - it('correctly produces `succeeded` authentication result without state.', () => { - const user = { username: 'user' } as AuthenticatedUser; + it('correctly produces `succeeded` authentication result without state and authHeaders.', () => { + const user = mockAuthenticatedUser(); const authenticationResult = AuthenticationResult.succeeded(user); expect(authenticationResult.succeeded()).toBe(true); @@ -88,14 +91,15 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); - it('correctly produces `succeeded` authentication result with state.', () => { - const user = { username: 'user' } as AuthenticatedUser; + it('correctly produces `succeeded` authentication result with state, but without authHeaders.', () => { + const user = mockAuthenticatedUser(); const state = { some: 'state' }; - const authenticationResult = AuthenticationResult.succeeded(user, state); + const authenticationResult = AuthenticationResult.succeeded(user, { state }); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.failed()).toBe(false); @@ -104,6 +108,42 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.error).toBeUndefined(); + expect(authenticationResult.redirectURL).toBeUndefined(); + }); + + it('correctly produces `succeeded` authentication result with authHeaders, but without state.', () => { + const user = mockAuthenticatedUser(); + const authHeaders = { authorization: 'some-token' }; + const authenticationResult = AuthenticationResult.succeeded(user, { authHeaders }); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + expect(authenticationResult.redirected()).toBe(false); + + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBe(authHeaders); + expect(authenticationResult.error).toBeUndefined(); + expect(authenticationResult.redirectURL).toBeUndefined(); + }); + + it('correctly produces `succeeded` authentication result with both authHeaders and state.', () => { + const user = mockAuthenticatedUser(); + const authHeaders = { authorization: 'some-token' }; + const state = { some: 'state' }; + const authenticationResult = AuthenticationResult.succeeded(user, { authHeaders, state }); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + expect(authenticationResult.redirected()).toBe(false); + + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBe(authHeaders); expect(authenticationResult.error).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); @@ -128,6 +168,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirectURL).toBe(redirectURL); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); @@ -143,6 +184,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirectURL).toBe(redirectURL); expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); @@ -176,21 +218,31 @@ describe('AuthenticationResult', () => { }); it('depends on `state` for `succeeded`.', () => { - const mockUser = { username: 'u' } as AuthenticatedUser; - expect(AuthenticationResult.succeeded(mockUser, 'string').shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, 0).shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, true).shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, false).shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldUpdateState()).toBe( + const mockUser = mockAuthenticatedUser(); + expect( + AuthenticationResult.succeeded(mockUser, { state: 'string' }).shouldUpdateState() + ).toBe(true); + expect(AuthenticationResult.succeeded(mockUser, { state: 0 }).shouldUpdateState()).toBe(true); + expect(AuthenticationResult.succeeded(mockUser, { state: true }).shouldUpdateState()).toBe( true ); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldUpdateState()).toBe( + expect(AuthenticationResult.succeeded(mockUser, { state: false }).shouldUpdateState()).toBe( true ); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldUpdateState() + ).toBe(true); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldUpdateState() + ).toBe(true); expect(AuthenticationResult.succeeded(mockUser).shouldUpdateState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, undefined).shouldUpdateState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, null).shouldUpdateState()).toBe(false); + expect( + AuthenticationResult.succeeded(mockUser, { state: undefined }).shouldUpdateState() + ).toBe(false); + expect(AuthenticationResult.succeeded(mockUser, { state: null }).shouldUpdateState()).toBe( + false + ); }); }); @@ -222,21 +274,31 @@ describe('AuthenticationResult', () => { }); it('depends on `state` for `succeeded`.', () => { - const mockUser = { username: 'u' } as AuthenticatedUser; - expect(AuthenticationResult.succeeded(mockUser, null).shouldClearState()).toBe(true); + const mockUser = mockAuthenticatedUser(); + expect(AuthenticationResult.succeeded(mockUser, { state: null }).shouldClearState()).toBe( + true + ); expect(AuthenticationResult.succeeded(mockUser).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, undefined).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, 'string').shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, 0).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, true).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, false).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldClearState()).toBe( + expect( + AuthenticationResult.succeeded(mockUser, { state: undefined }).shouldClearState() + ).toBe(false); + expect(AuthenticationResult.succeeded(mockUser, { state: 'string' }).shouldClearState()).toBe( + false + ); + expect(AuthenticationResult.succeeded(mockUser, { state: 0 }).shouldClearState()).toBe(false); + expect(AuthenticationResult.succeeded(mockUser, { state: true }).shouldClearState()).toBe( false ); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldClearState()).toBe( + expect(AuthenticationResult.succeeded(mockUser, { state: false }).shouldClearState()).toBe( false ); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldClearState() + ).toBe(false); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldClearState() + ).toBe(false); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts index e3227399026514..27e3f51191c1bf 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -/** - * Represents status that `AuthenticationResult` can be in. - */ +import { AuthHeaders } from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../common/model'; import { getErrorStatusCode } from '../errors'; +/** + * Represents status that `AuthenticationResult` can be in. + */ enum AuthenticationResultStatus { /** * Authentication of the user can't be handled (e.g. supported credentials @@ -45,6 +46,7 @@ interface AuthenticationOptions { redirectURL?: string; state?: unknown; user?: AuthenticatedUser; + authHeaders?: AuthHeaders; } /** @@ -62,14 +64,22 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication succeeds. * @param user User information retrieved as a result of successful authentication attempt. + * @param [authHeaders] Optional dictionary of the HTTP headers with authentication information. * @param [state] Optional state to be stored and reused for the next request. */ - public static succeeded(user: AuthenticatedUser, state?: unknown) { + public static succeeded( + user: AuthenticatedUser, + { authHeaders, state }: { authHeaders?: AuthHeaders; state?: unknown } = {} + ) { if (!user) { throw new Error('User should be specified.'); } - return new AuthenticationResult(AuthenticationResultStatus.Succeeded, { user, state }); + return new AuthenticationResult(AuthenticationResultStatus.Succeeded, { + user, + authHeaders, + state, + }); } /** @@ -112,6 +122,14 @@ export class AuthenticationResult { return this.options.user; } + /** + * Headers that include authentication information that should be used to authenticate user for any + * future requests (only available for `succeeded` result). + */ + public get authHeaders() { + return this.options.authHeaders; + } + /** * State associated with the authenticated user (only available for `succeeded` * and `redirected` results). diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 7c5a76dbc1d765..1e2595c33002d1 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -4,570 +4,652 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; +jest.mock('./providers/basic', () => ({ BasicAuthenticationProvider: jest.fn() })); + import Boom from 'boom'; -import { Legacy } from 'kibana'; +import { SessionStorage } from '../../../../../src/core/server'; -import { serverFixture } from '../__tests__/__fixtures__/server'; -import { requestFixture } from '../__tests__/__fixtures__/request'; +import { loggingServiceMock, httpServiceMock } from '../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { requestFixture } from '../__fixtures__'; import { AuthenticationResult } from './authentication_result'; +import { Authenticator, AuthenticatorOptions } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; -import { Session } from './session'; -import { LoginAttempt } from './login_attempt'; -import { initAuthenticator } from './authenticator'; -import * as ClientShield from '../../../../legacy/server/lib/get_client_shield'; +import { BasicAuthenticationProvider } from './providers'; + +function getMockOptions(config: Partial = {}) { + return { + clusterClient: { callAsInternalUser: jest.fn(), asScoped: jest.fn(), close: jest.fn() }, + basePath: httpServiceMock.createSetupContract().basePath, + loggers: loggingServiceMock.create(), + isSystemAPIRequest: jest.fn(), + config: { sessionTimeout: null, authc: { providers: [], oidc: {}, saml: {} }, ...config }, + sessionStorageFactory: { + asScoped: jest.fn().mockReturnValue(getMockSessionStorage()), + }, + }; +} + +function getMockSessionStorage() { + return { get: jest.fn(), set: jest.fn(), clear: jest.fn() }; +} describe('Authenticator', () => { - const sandbox = sinon.createSandbox(); - - let config: sinon.SinonStubbedInstance; - let server: ReturnType; - let session: sinon.SinonStubbedInstance; - let cluster: sinon.SinonStubbedInstance<{ - callWithRequest: (request: ReturnType, ...args: any[]) => any; - callWithInternalUser: (...args: any[]) => any; - }>; + let mockBasicAuthenticationProvider: jest.Mocked>; beforeEach(() => { - server = serverFixture(); - session = sinon.createStubInstance(Session); - - config = { get: sinon.stub(), has: sinon.stub() }; - - // Cluster is returned by `getClient` function that is wrapped into `once` making cluster - // a static singleton, so we should use sandbox to set/reset its behavior between tests. - cluster = sinon.stub({ callWithRequest() {}, callWithInternalUser() {} }); - sandbox.stub(ClientShield, 'getClient').returns(cluster); - - server.config.returns(config); - server.register.yields(); - - sandbox - .stub(Session, 'create') - .withArgs(server as any) - .resolves(session as any); - - sandbox.useFakeTimers(); + mockBasicAuthenticationProvider = { + login: jest.fn(), + authenticate: jest.fn(), + logout: jest.fn(), + }; + + jest + .requireMock('./providers/basic') + .BasicAuthenticationProvider.mockImplementation(() => mockBasicAuthenticationProvider); }); - afterEach(() => sandbox.restore()); + afterEach(() => jest.clearAllMocks()); describe('initialization', () => { - it('fails if authentication providers are not configured.', async () => { - config.get.withArgs('xpack.security.authc.providers').returns([]); - - await expect(initAuthenticator(server as any)).rejects.toThrowError( + it('fails if authentication providers are not configured.', () => { + const mockOptions = getMockOptions({ authc: { providers: [], oidc: {}, saml: {} } }); + expect(() => new Authenticator(mockOptions)).toThrowError( 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' ); }); - it('fails if configured authentication provider is not known.', async () => { - config.get.withArgs('xpack.security.authc.providers').returns(['super-basic']); + it('fails if configured authentication provider is not known.', () => { + const mockOptions = getMockOptions({ + authc: { providers: ['super-basic'], oidc: {}, saml: {} }, + }); - await expect(initAuthenticator(server as any)).rejects.toThrowError( + expect(() => new Authenticator(mockOptions)).toThrowError( 'Unsupported authentication provider name: super-basic.' ); }); }); - describe('`authenticate` method', () => { - let authenticate: (request: ReturnType) => Promise; - beforeEach(async () => { - config.get.withArgs('xpack.security.authc.providers').returns(['basic']); - server.plugins.kibana.systemApi.isSystemApiRequest.returns(true); - session.clear.throws(new Error('`Session.clear` is not supposed to be called!')); - - await initAuthenticator(server as any); + describe('`login` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockSessionStorage = getMockSessionStorage(); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - // Second argument will be a method we'd like to test. - authenticate = server.expose.withArgs('authenticate').firstCall.args[1]; + authenticator = new Authenticator(mockOptions); }); it('fails if request is not provided.', async () => { - await expect(authenticate(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' + await expect(authenticator.login(undefined as any, undefined as any)).rejects.toThrowError( + 'Request should be a valid "KibanaRequest" instance, was [undefined].' ); }); - it('fails if any authentication providers fail.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic ***' } }); - session.get.withArgs(request).resolves(null); + it('fails if login attempt is not provided.', async () => { + await expect(authenticator.login(requestFixture(), undefined as any)).rejects.toThrowError( + 'Login attempt should be an object with non-empty "provider" property.' + ); + + await expect(authenticator.login(requestFixture(), {} as any)).rejects.toThrowError( + 'Login attempt should be an object with non-empty "provider" property.' + ); + }); + it('fails if an authentication provider fails.', async () => { + const request = requestFixture(); const failureReason = new Error('Not Authorized'); - cluster.callWithRequest.withArgs(request).rejects(failureReason); - const authenticationResult = await authenticate(request); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); + + const authenticationResult = await authenticator.login(request, { + provider: 'basic', + value: {}, + }); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); }); it('returns user that authentication provider returns.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic ***' } }); - const user = { username: 'user' }; - cluster.callWithRequest.withArgs(request).resolves(user); + const request = requestFixture(); + + const user = mockAuthenticatedUser(); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) + ); - const authenticationResult = await authenticate(request); + const authenticationResult = await authenticator.login(request, { + provider: 'basic', + value: {}, + }); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Basic .....' }); }); it('creates session whenever authentication provider returns state for system API requests', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); - const loginAttempt = new LoginAttempt(); const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; - loginAttempt.setCredentials('foo', 'bar'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(true); - - cluster.callWithRequest.withArgs(request).resolves(user); + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); - const systemAPIAuthenticationResult = await authenticate(request); + const systemAPIAuthenticationResult = await authenticator.login(request, { + provider: 'basic', + value: {}, + }); expect(systemAPIAuthenticationResult.succeeded()).toBe(true); expect(systemAPIAuthenticationResult.user).toEqual(user); - sinon.assert.calledOnce(session.set); - sinon.assert.calledWithExactly(session.set, request, { + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, state: { authorization }, provider: 'basic', }); }); it('creates session whenever authentication provider returns state for non-system API requests', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); - const loginAttempt = new LoginAttempt(); const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; - loginAttempt.setCredentials('foo', 'bar'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(false); - cluster.callWithRequest.withArgs(request).resolves(user); + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); - const notSystemAPIAuthenticationResult = await authenticate(request); - expect(notSystemAPIAuthenticationResult.succeeded()).toBe(true); - expect(notSystemAPIAuthenticationResult.user).toEqual(user); - sinon.assert.calledOnce(session.set); - sinon.assert.calledWithExactly(session.set, request, { - state: { authorization }, + const systemAPIAuthenticationResult = await authenticator.login(request, { provider: 'basic', + value: {}, }); - }); - - it('extends session only for non-system API calls.', async () => { - const user = { username: 'user' }; - const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } }); - const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } }); + expect(systemAPIAuthenticationResult.succeeded()).toBe(true); + expect(systemAPIAuthenticationResult.user).toEqual(user); - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state: { authorization }, provider: 'basic', }); + }); - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, - provider: 'basic', + it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { + const request = requestFixture(); + const authenticationResult = await authenticator.login(request, { + provider: 'token', + value: {}, }); + expect(authenticationResult.notHandled()).toBe(true); + }); - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); + it('clears session if it belongs to a different provider.', async () => { + const state = { authorization: 'Basic xxx' }; + const user = mockAuthenticatedUser(); + const credentials = { username: 'user', password: 'password' }; + const request = requestFixture(); - cluster.callWithRequest - .withArgs(systemAPIRequest) - .resolves(user) - .withArgs(notSystemAPIRequest) - .resolves(user); + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.succeeded()).toBe(true); - expect(systemAPIAuthenticationResult.user).toEqual(user); - sinon.assert.notCalled(session.set); - - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.succeeded()).toBe(true); - expect(notSystemAPIAuthenticationResult.user).toEqual(user); - sinon.assert.calledOnce(session.set); - sinon.assert.calledWithExactly(session.set, notSystemAPIRequest, { - state: { authorization: 'Basic yyy' }, + const authenticationResult = await authenticator.login(request, { provider: 'basic', + value: credentials, }); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith( + request, + credentials, + null + ); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + }); - it('does not extend session if authentication fails.', async () => { - const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } }); - const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } }); + describe('`authenticate` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockSessionStorage = getMockSessionStorage(); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, - provider: 'basic', - }); + it('fails if request is not provided.', async () => { + await expect(authenticator.authenticate(undefined as any)).rejects.toThrowError( + 'Request should be a valid "KibanaRequest" instance, was [undefined].' + ); + }); - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, - provider: 'basic', - }); + it('fails if an authentication provider fails.', async () => { + const request = requestFixture(); + const failureReason = new Error('Not Authorized'); - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); - cluster.callWithRequest - .withArgs(systemAPIRequest) - .rejects(new Error('some error')) - .withArgs(notSystemAPIRequest) - .rejects(new Error('some error')); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.failed()).toBe(true); + it('returns user that authentication provider returns.', async () => { + const request = requestFixture({ headers: { authorization: 'Basic ***' } }); - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.failed()).toBe(true); + const user = mockAuthenticatedUser(); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) + ); - sinon.assert.notCalled(session.clear); - sinon.assert.notCalled(session.set); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Basic .....' }); }); - it('replaces existing session with the one returned by authentication provider for system API requests', async () => { - const user = { username: 'user' }; - const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; + it('creates session whenever authentication provider returns state for system API requests', async () => { + const user = mockAuthenticatedUser(); const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('foo', 'bar'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - session.get.withArgs(request).resolves({ - state: { authorization: 'Basic some-old-token' }, - provider: 'basic', - }); + const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; - server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(true); + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); - cluster.callWithRequest.withArgs(request).resolves(user); + const systemAPIAuthenticationResult = await authenticator.authenticate(request); + expect(systemAPIAuthenticationResult.succeeded()).toBe(true); + expect(systemAPIAuthenticationResult.user).toEqual(user); - const authenticationResult = await authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - sinon.assert.calledOnce(session.set); - sinon.assert.calledWithExactly(session.set, request, { + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, state: { authorization }, provider: 'basic', }); }); - it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => { - const user = { username: 'user' }; - const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; + it('creates session whenever authentication provider returns state for non-system API requests', async () => { + const user = mockAuthenticatedUser(); const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('foo', 'bar'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); + const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; + + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); + + const systemAPIAuthenticationResult = await authenticator.authenticate(request); + expect(systemAPIAuthenticationResult.succeeded()).toBe(true); + expect(systemAPIAuthenticationResult.user).toEqual(user); - session.get.withArgs(request).resolves({ - state: { authorization: 'Basic some-old-token' }, + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state: { authorization }, provider: 'basic', }); + }); - server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(false); + it('does not extend session for system API calls.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - cluster.callWithRequest.withArgs(request).resolves(user); + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - const authenticationResult = await authenticate(request); + const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); - sinon.assert.calledOnce(session.set); - sinon.assert.calledWithExactly(session.set, request, { - state: { authorization }, - provider: 'basic', - }); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('clears session if provider failed to authenticate request with 401 with active session.', async () => { - const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } }); - const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } }); + it('extends session for non-system API calls.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, - provider: 'basic', - }); + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state, provider: 'basic', }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); - session.clear.resolves(); + it('properly extends session timeout if it is defined.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); + const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); + // Create new authenticator with non-null `sessionTimeout`. + mockOptions = getMockOptions({ + sessionTimeout: 3600 * 24, + authc: { providers: ['basic'], oidc: {}, saml: {} }, + }); - cluster.callWithRequest - .withArgs(systemAPIRequest) - .rejects(Boom.unauthorized('token expired')) - .withArgs(notSystemAPIRequest) - .rejects(Boom.unauthorized('invalid token')); + mockSessionStorage = getMockSessionStorage(); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.failed()).toBe(true); + authenticator = new Authenticator(mockOptions); - sinon.assert.calledOnce(session.clear); - sinon.assert.calledWithExactly(session.clear, systemAPIRequest); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.failed()).toBe(true); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); - sinon.assert.calledTwice(session.clear); - sinon.assert.calledWithExactly(session.clear, notSystemAPIRequest); + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: currentDate + 3600 * 24, + state, + provider: 'basic', + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('clears session if provider requested it via setting state to `null`.', async () => { - // Use `token` provider for this test as it's the only one that does what we want. - config.get.withArgs('xpack.security.authc.providers').returns(['token']); - await initAuthenticator(server as any); - authenticate = server.expose.withArgs('authenticate').lastCall.args[1]; + it('does not touch session for system API calls if authentication fails with non-401 reason.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - const request = requestFixture({ headers: { xCustomHeader: 'xxx' } }); + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(new Error('some error')) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - session.get.withArgs(request).resolves({ - state: { accessToken: 'access-xxx', refreshToken: 'refresh-xxx' }, - provider: 'token', - }); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); - session.clear.resolves(); + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); - cluster.callWithRequest.withArgs(request).rejects({ statusCode: 401 }); + it('does not touch session for non-system API calls if authentication fails with non-401 reason.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - cluster.callWithInternalUser - .withArgs('shield.getAccessToken') - .rejects(Boom.badRequest('refresh token expired')); + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(new Error('some error')) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - const authenticationResult = await authenticate(request); - expect(authenticationResult.redirected()).toBe(true); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); - sinon.assert.calledOnce(session.clear); - sinon.assert.calledWithExactly(session.clear, request); + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('does not clear session if provider failed to authenticate request with non-401 reason with active session.', async () => { - const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } }); - const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } }); + it('replaces existing session with the one returned by authentication provider for system API requests', async () => { + const user = mockAuthenticatedUser(); + const existingState = { authorization: 'Basic xxx' }; + const newState = { authorization: 'Basic yyy' }; + const request = requestFixture(); - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: newState }) + ); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + state: existingState, provider: 'basic', }); - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state: newState, provider: 'basic', }); - - session.clear.resolves(); - - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); - - cluster.callWithRequest - .withArgs(systemAPIRequest) - .rejects(Boom.badRequest('something went wrong')) - .withArgs(notSystemAPIRequest) - .rejects(new Error('Non boom error')); - - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.failed()).toBe(true); - - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.failed()).toBe(true); - - sinon.assert.notCalled(session.clear); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('does not clear session if provider can not handle request authentication with active session.', async () => { - // Add `kbn-xsrf` header to the raw part of the request to make `can_redirect_request` - // think that it's AJAX request and redirect logic shouldn't be triggered. - const systemAPIRequest = requestFixture({ - headers: { xCustomHeader: 'xxx', 'kbn-xsrf': 'xsrf' }, - }); - const notSystemAPIRequest = requestFixture({ - headers: { xCustomHeader: 'yyy', 'kbn-xsrf': 'xsrf' }, - }); + it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => { + const user = mockAuthenticatedUser(); + const existingState = { authorization: 'Basic xxx' }; + const newState = { authorization: 'Basic yyy' }; + const request = requestFixture(); - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Some weird authentication schema...' }, + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: newState }) + ); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + state: existingState, provider: 'basic', }); - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Some weird authentication schema...' }, + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state: newState, provider: 'basic', }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); - session.clear.resolves(); - - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); + it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.failed()).toBe(true); + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(Boom.unauthorized()) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.failed()).toBe(true); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); - sinon.assert.notCalled(session.clear); + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); }); - it('clears session if it belongs to not configured provider.', async () => { - // Add `kbn-xsrf` header to the raw part of the request to make `can_redirect_request` - // think that it's AJAX request and redirect logic shouldn't be triggered. - const systemAPIRequest = requestFixture({ - headers: { xCustomHeader: 'xxx', 'kbn-xsrf': 'xsrf' }, - }); - const notSystemAPIRequest = requestFixture({ - headers: { xCustomHeader: 'yyy', 'kbn-xsrf': 'xsrf' }, - }); + it('clears session if provider failed to authenticate non-system API request with 401 with active session.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - session.get.withArgs(systemAPIRequest).resolves({ - state: { accessToken: 'some old token' }, - provider: 'token', - }); + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(Boom.unauthorized()) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { accessToken: 'some old token' }, - provider: 'token', - }); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); - session.clear.resolves(); + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); + it('clears session if provider requested it via setting state to `null`.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.redirectTo('some-url', null) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.notHandled()).toBe(true); - sinon.assert.calledOnce(session.clear); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.redirected()).toBe(true); - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.notHandled()).toBe(true); - sinon.assert.calledTwice(session.clear); + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); }); - }); - describe('`deauthenticate` method', () => { - let deauthenticate: ( - request: ReturnType - ) => Promise; - beforeEach(async () => { - config.get.withArgs('xpack.security.authc.providers').returns(['basic']); - config.get.withArgs('server.basePath').returns('/base-path'); + it('does not clear session if provider can not handle system API request authentication with active session.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - await initAuthenticator(server as any); + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.notHandled() + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - // Second argument will be a method we'd like to test. - deauthenticate = server.expose.withArgs('deauthenticate').firstCall.args[1]; - }); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.notHandled()).toBe(true); - it('fails if request is not provided.', async () => { - await expect(deauthenticate(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('returns `notHandled` if session does not exist.', async () => { + it('does not clear session if provider can not handle non-system API request authentication with active session.', async () => { + const state = { authorization: 'Basic xxx' }; const request = requestFixture(); - session.get.withArgs(request).resolves(null); - const deauthenticationResult = await deauthenticate(request); + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.notHandled() + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); - expect(deauthenticationResult.notHandled()).toBe(true); - sinon.assert.notCalled(session.clear); - }); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.notHandled()).toBe(true); - it('clears session and returns whatever authentication provider returns.', async () => { - const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' }); - session.get.withArgs(request).resolves({ - state: {}, - provider: 'basic', - }); + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); - const deauthenticationResult = await deauthenticate(request); + it('clears session for system API request if it belongs to not configured provider.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - sinon.assert.calledOnce(session.clear); - sinon.assert.calledWithExactly(session.clear, request); - expect(deauthenticationResult.redirected()).toBe(true); - expect(deauthenticationResult.redirectURL).toBe( - '/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.notHandled() ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.notHandled()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); }); - it('only clears session if it belongs to not configured provider.', async () => { - const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' }); - session.get.withArgs(request).resolves({ - state: {}, - provider: 'token', - }); + it('clears session for non-system API request if it belongs to not configured provider.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = requestFixture(); - const deauthenticationResult = await deauthenticate(request); + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.notHandled() + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); - sinon.assert.calledOnce(session.clear); - sinon.assert.calledWithExactly(session.clear, request); - expect(deauthenticationResult.notHandled()).toBe(true); + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.notHandled()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); }); }); - describe('`isAuthenticated` method', () => { - let isAuthenticated: (request: ReturnType) => Promise; - beforeEach(async () => { - config.get.withArgs('xpack.security.authc.providers').returns(['basic']); - - await initAuthenticator(server as any); + describe('`logout` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockSessionStorage = getMockSessionStorage(); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - // Second argument will be a method we'd like to test. - isAuthenticated = server.expose.withArgs('isAuthenticated').firstCall.args[1]; + authenticator = new Authenticator(mockOptions); }); - it('returns `true` if `getUser` succeeds.', async () => { - const request = requestFixture(); - server.plugins.security.getUser.withArgs(request).resolves({}); - - await expect(isAuthenticated(request)).resolves.toBe(true); + it('fails if request is not provided.', async () => { + await expect(authenticator.logout(undefined as any)).rejects.toThrowError( + 'Request should be a valid "KibanaRequest" instance, was [undefined].' + ); }); - it('returns `false` when `getUser` throws a 401 boom error.', async () => { + it('returns `notHandled` if session does not exist.', async () => { const request = requestFixture(); - server.plugins.security.getUser.withArgs(request).rejects(Boom.unauthorized()); + mockSessionStorage.get.mockResolvedValue(null); - await expect(isAuthenticated(request)).resolves.toBe(false); + const deauthenticationResult = await authenticator.logout(request); + + expect(deauthenticationResult.notHandled()).toBe(true); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('throw non-boom errors.', async () => { + it('clears session and returns whatever authentication provider returns.', async () => { const request = requestFixture(); - const nonBoomError = new TypeError(); - server.plugins.security.getUser.withArgs(request).rejects(nonBoomError); + const state = { authorization: 'Basic xxx' }; + mockBasicAuthenticationProvider.logout.mockResolvedValue( + DeauthenticationResult.redirectTo('some-url') + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const deauthenticationResult = await authenticator.logout(request); - await expect(isAuthenticated(request)).rejects.toThrowError(nonBoomError); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(deauthenticationResult.redirected()).toBe(true); + expect(deauthenticationResult.redirectURL).toBe('some-url'); }); - it('throw non-401 boom errors.', async () => { + it('only clears session if it belongs to not configured provider.', async () => { const request = requestFixture(); - const non401Error = Boom.boomify(new TypeError()); - server.plugins.security.getUser.withArgs(request).rejects(non401Error); + const state = { authorization: 'Bearer xxx' }; + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + + const deauthenticationResult = await authenticator.logout(request); - await expect(isAuthenticated(request)).rejects.toThrowError(non401Error); + expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(deauthenticationResult.notHandled()).toBe(true); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 4a3c6575ace4e6..f288669b0972ba 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -4,31 +4,79 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; -import { getClient } from '../../../../legacy/server/lib/get_client_shield'; +import { + SessionStorageFactory, + SessionStorage, + KibanaRequest, + LoggerFactory, + Logger, + HttpServiceSetup, + ClusterClient, +} from '../../../../../src/core/server'; +import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; + import { AuthenticationProviderOptions, + AuthenticationProviderSpecificOptions, BaseAuthenticationProvider, BasicAuthenticationProvider, KerberosAuthenticationProvider, - RequestWithLoginAttempt, SAMLAuthenticationProvider, TokenAuthenticationProvider, OIDCAuthenticationProvider, + isSAMLRequestQuery, } from './providers'; import { AuthenticationResult } from './authentication_result'; import { DeauthenticationResult } from './deauthentication_result'; -import { Session } from './session'; -import { LoginAttempt } from './login_attempt'; -import { AuthenticationProviderSpecificOptions } from './providers/base'; import { Tokens } from './tokens'; -interface ProviderSession { +/** + * The shape of the session that is actually stored in the cookie. + */ +export interface ProviderSession { + /** + * Name/type of the provider this session belongs to. + */ provider: string; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + expires: number | null; + + /** + * Session value that is fed to the authentication provider. The shape is unknown upfront and + * entirely determined by the authentication provider that owns the current session. + */ state: unknown; } +/** + * The shape of the login attempt. + */ +export interface ProviderLoginAttempt { + /** + * Name/type of the provider this login attempt is targeted for. + */ + provider: string; + + /** + * Login attempt can have any form and defined by the specific provider. + */ + value: unknown; +} + +export interface AuthenticatorOptions { + config: Pick; + basePath: HttpServiceSetup['basePath']; + loggers: LoggerFactory; + clusterClient: PublicMethodsOf; + sessionStorageFactory: SessionStorageFactory; + isSystemAPIRequest: (request: KibanaRequest) => boolean; +} + // Mapping between provider key defined in the config and authentication // provider class that can handle specific authentication mechanism. const providerMap = new Map< @@ -45,43 +93,15 @@ const providerMap = new Map< ['oidc', OIDCAuthenticationProvider], ]); -function assertRequest(request: Legacy.Request) { - if (!request || typeof request !== 'object') { - throw new Error(`Request should be a valid object, was [${typeof request}].`); +function assertRequest(request: KibanaRequest) { + if (!request || !(request instanceof KibanaRequest)) { + throw new Error(`Request should be a valid "KibanaRequest" instance, was [${typeof request}].`); } } -/** - * Prepares options object that is shared among all authentication providers. - * @param server Server instance. - */ -function getProviderOptions(server: Legacy.Server) { - const config = server.config(); - const client = getClient(server); - const log = server.log.bind(server); - - return { - client, - log, - basePath: config.get('server.basePath'), - tokens: new Tokens({ client, log }), - }; -} - -/** - * Prepares options object that is specific only to an authentication provider. - * @param server Server instance. - * @param providerType the type of the provider to get the options for. - */ -function getProviderSpecificOptions( - server: Legacy.Server, - providerType: string -): AuthenticationProviderSpecificOptions | undefined { - const config = server.config(); - - const providerOptionsConfigKey = `xpack.security.authc.${providerType}`; - if (config.has(providerOptionsConfigKey)) { - return config.get(providerOptionsConfigKey); +function assertLoginAttempt(attempt: ProviderLoginAttempt) { + if (!attempt || !attempt.provider || typeof attempt.provider !== 'string') { + throw new Error('Login attempt should be an object with non-empty "provider" property.'); } } @@ -117,50 +137,125 @@ function instantiateProvider( * the authentication is then considered to be unsuccessful and an authentication error * will be returned. */ -class Authenticator { +export class Authenticator { /** * List of configured and instantiated authentication providers. */ private readonly providers: Map; + /** + * Session duration in ms. If `null` session will stay active until the browser is closed. + */ + private readonly ttl: number | null = null; + + /** + * Internal authenticator logger. + */ + private readonly logger: Logger; + /** * Instantiates Authenticator and bootstrap configured providers. - * @param server Server instance. - * @param session Session instance. + * @param options Authenticator options. */ - constructor(private readonly server: Legacy.Server, private readonly session: Session) { - const config = this.server.config(); - const authProviders = config.get('xpack.security.authc.providers'); + constructor(private readonly options: Readonly) { + this.logger = options.loggers.get('authenticator'); + + const providerCommonOptions = { + client: this.options.clusterClient, + basePath: this.options.basePath, + tokens: new Tokens({ + client: this.options.clusterClient, + logger: this.options.loggers.get('tokens'), + }), + }; + + const authProviders = this.options.config.authc.providers; if (authProviders.length === 0) { throw new Error( 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' ); } - const providerOptions = Object.freeze(getProviderOptions(server)); - this.providers = new Map( authProviders.map(providerType => { - const providerSpecificOptions = getProviderSpecificOptions(server, providerType); + const providerSpecificOptions = this.options.config.authc.hasOwnProperty(providerType) + ? (this.options.config.authc as Record)[providerType] + : undefined; + return [ providerType, - instantiateProvider(providerType, providerOptions, providerSpecificOptions), + instantiateProvider( + providerType, + Object.freeze({ ...providerCommonOptions, logger: options.loggers.get(providerType) }), + providerSpecificOptions + ), ] as [string, BaseAuthenticationProvider]; }) ); + + this.ttl = this.options.config.sessionTimeout; + } + + /** + * Performs the initial login request using the provider login attempt description. + * @param request Request instance. + * @param attempt Login attempt description. + */ + async login(request: KibanaRequest, attempt: ProviderLoginAttempt) { + assertRequest(request); + assertLoginAttempt(attempt); + + // If there is an attempt to login with a provider that isn't enabled, we should fail. + const provider = this.providers.get(attempt.provider); + if (provider === undefined) { + this.logger.debug( + `Login attempt for provider "${attempt.provider}" is detected, but it isn't enabled.` + ); + return AuthenticationResult.notHandled(); + } + + this.logger.debug(`Performing login using "${attempt.provider}" provider.`); + + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + + // If we detect an existing session that belongs to a different provider than the one request to + // perform a login we should clear such session. + let existingSession = await this.getSessionValue(sessionStorage); + if (existingSession && existingSession.provider !== attempt.provider) { + this.logger.debug( + `Clearing existing session of another ("${existingSession.provider}") provider.` + ); + sessionStorage.clear(); + existingSession = null; + } + + const authenticationResult = await provider.login( + request, + attempt.value, + existingSession ? existingSession.state : null + ); + + this.updateSessionValue(sessionStorage, { + providerType: attempt.provider, + isSystemAPIRequest: this.options.isSystemAPIRequest(request), + authenticationResult, + existingSession, + }); + + return authenticationResult; } /** * Performs request authentication using configured chain of authentication providers. * @param request Request instance. */ - async authenticate(request: RequestWithLoginAttempt) { + async authenticate(request: KibanaRequest) { assertRequest(request); - const isSystemApiRequest = this.server.plugins.kibana.systemApi.isSystemApiRequest(request); - const existingSession = await this.getSessionValue(request); + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const existingSession = await this.getSessionValue(sessionStorage); - let authenticationResult; + let authenticationResult = AuthenticationResult.notHandled(); for (const [providerType, provider] of this.providerIterator(existingSession)) { // Check if current session has been set by this provider. const ownsSession = existingSession && existingSession.provider === providerType; @@ -170,39 +265,19 @@ class Authenticator { ownsSession ? existingSession!.state : null ); - if (ownsSession || authenticationResult.shouldUpdateState()) { - // If authentication succeeds or requires redirect we should automatically extend existing user session, - // unless authentication has been triggered by a system API request. In case provider explicitly returns new - // state we should store it in the session regardless of whether it's a system API request or not. - const sessionCanBeUpdated = - (authenticationResult.succeeded() || authenticationResult.redirected()) && - (authenticationResult.shouldUpdateState() || !isSystemApiRequest); - - // If provider owned the session, but failed to authenticate anyway, that likely means that - // session is not valid and we should clear it. Also provider can specifically ask to clear - // session by setting it to `null` even if authentication attempt didn't fail. - if ( - authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) - ) { - await this.session.clear(request); - } else if (sessionCanBeUpdated) { - await this.session.set( - request, - authenticationResult.shouldUpdateState() - ? { state: authenticationResult.state, provider: providerType } - : existingSession - ); - } - } - - if (authenticationResult.failed()) { - return authenticationResult; - } - - if (authenticationResult.succeeded()) { - return AuthenticationResult.succeeded(authenticationResult.user!); - } else if (authenticationResult.redirected()) { + this.updateSessionValue(sessionStorage, { + providerType, + isSystemAPIRequest: this.options.isSystemAPIRequest(request), + authenticationResult, + existingSession: + existingSession && existingSession.provider === providerType ? existingSession : null, + }); + + if ( + authenticationResult.failed() || + authenticationResult.succeeded() || + authenticationResult.redirected() + ) { return authenticationResult; } } @@ -214,24 +289,25 @@ class Authenticator { * Deauthenticates current request. * @param request Request instance. */ - async deauthenticate(request: RequestWithLoginAttempt) { + async logout(request: KibanaRequest) { assertRequest(request); - const sessionValue = await this.getSessionValue(request); + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const sessionValue = await this.getSessionValue(sessionStorage); if (sessionValue) { - await this.session.clear(request); + sessionStorage.clear(); - return this.providers.get(sessionValue.provider)!.deauthenticate(request, sessionValue.state); + return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state); } - // Normally when there is no active session in Kibana, `deauthenticate` method shouldn't do anything + // Normally when there is no active session in Kibana, `logout` method shouldn't do anything // and user will eventually be redirected to the home page to log in. But if SAML is supported there // is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_ // SP associated with the current user session to do the logout. So if Kibana (without active session) // receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP // with correct logout response and only Elasticsearch knows how to do that. - if ((request.query as Record).SAMLRequest && this.providers.has('saml')) { - return this.providers.get('saml')!.deauthenticate(request); + if (isSAMLRequestQuery(request.query) && this.providers.has('saml')) { + return this.providers.get('saml')!.logout(request); } return DeauthenticationResult.notHandled(); @@ -240,20 +316,25 @@ class Authenticator { /** * Returns provider iterator where providers are sorted in the order of priority (based on the session ownership). * @param sessionValue Current session value. + * @param [loginAttempt] Optional provider login attempt. If present, login attempt always has a higher + * priority comparing to the existing session. */ - *providerIterator( - sessionValue: ProviderSession | null + private *providerIterator( + sessionValue: ProviderSession | null, + loginAttempt?: ProviderLoginAttempt ): IterableIterator<[string, BaseAuthenticationProvider]> { - // If there is no session to predict which provider to use first, let's use the order - // providers are configured in. Otherwise return provider that owns session first, and only then the rest - // of providers. - if (!sessionValue) { + // If there is no session or login attempt to predict which provider to use first, let's use the order + // providers are configured in. Otherwise return provider that owns login attempt/session first, and + // only then the rest of providers. Login attempt always takes precedence over session. + const preferredProvider = + (loginAttempt && loginAttempt.provider) || (sessionValue && sessionValue.provider); + if (!preferredProvider) { yield* this.providers; } else { - yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; + yield [preferredProvider, this.providers.get(preferredProvider)!]; - for (const [providerType, provider] of this.providers) { - if (providerType !== sessionValue.provider) { + for (const [providerType, provider] of this.providers.entries()) { + if (providerType !== preferredProvider) { yield [providerType, provider]; } } @@ -263,54 +344,63 @@ class Authenticator { /** * Extracts session value for the specified request. Under the hood it can * clear session if it belongs to the provider that is not available. - * @param request Request to extract session value for. + * @param sessionStorage Session storage instance. */ - private async getSessionValue(request: Legacy.Request) { - let sessionValue = await this.session.get(request); + private async getSessionValue(sessionStorage: SessionStorage) { + let sessionValue = await sessionStorage.get(); // If for some reason we have a session stored for the provider that is not available // (e.g. when user was logged in with one provider, but then configuration has changed // and that provider is no longer available), then we should clear session entirely. if (sessionValue && !this.providers.has(sessionValue.provider)) { - await this.session.clear(request); + sessionStorage.clear(); sessionValue = null; } return sessionValue; } -} -export async function initAuthenticator(server: Legacy.Server) { - const session = await Session.create(server); - const authenticator = new Authenticator(server, session); - - const loginAttempts = new WeakMap(); - server.decorate('request', 'loginAttempt', function(this: Legacy.Request) { - const request = this; - if (!loginAttempts.has(request)) { - loginAttempts.set(request, new LoginAttempt()); + private updateSessionValue( + sessionStorage: SessionStorage, + { + providerType, + authenticationResult, + existingSession, + isSystemAPIRequest, + }: { + providerType: string; + authenticationResult: AuthenticationResult; + existingSession: ProviderSession | null; + isSystemAPIRequest: boolean; } - return loginAttempts.get(request); - }); - - server.expose('authenticate', (request: RequestWithLoginAttempt) => - authenticator.authenticate(request) - ); - server.expose('deauthenticate', (request: RequestWithLoginAttempt) => - authenticator.deauthenticate(request) - ); - - server.expose('isAuthenticated', async (request: Legacy.Request) => { - try { - await server.plugins.security!.getUser(request); - return true; - } catch (err) { - // Don't swallow server errors. - if (!err.isBoom || err.output.statusCode !== 401) { - throw err; - } + ) { + if (!existingSession && !authenticationResult.shouldUpdateState()) { + return; } - return false; - }); + // If authentication succeeds or requires redirect we should automatically extend existing user session, + // unless authentication has been triggered by a system API request. In case provider explicitly returns new + // state we should store it in the session regardless of whether it's a system API request or not. + const sessionCanBeUpdated = + (authenticationResult.succeeded() || authenticationResult.redirected()) && + (authenticationResult.shouldUpdateState() || !isSystemAPIRequest); + + // If provider owned the session, but failed to authenticate anyway, that likely means that + // session is not valid and we should clear it. Also provider can specifically ask to clear + // session by setting it to `null` even if authentication attempt didn't fail. + if ( + authenticationResult.shouldClearState() || + (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) + ) { + sessionStorage.clear(); + } else if (sessionCanBeUpdated) { + sessionStorage.set({ + state: authenticationResult.shouldUpdateState() + ? authenticationResult.state + : existingSession!.state, + provider: providerType, + expires: this.ttl && Date.now() + this.ttl, + }); + } + } } diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts index b132b39c7ae7e9..5d19ee7ac9a7fa 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts @@ -4,24 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { requestFixture } from './__tests__/__fixtures__/request'; +import { requestFixture } from '../__fixtures__'; import { canRedirectRequest } from './can_redirect_request'; -describe('lib/can_redirect_request', () => { +describe('can_redirect_request', () => { it('returns true if request does not have either a kbn-version or kbn-xsrf header', () => { expect(canRedirectRequest(requestFixture())).toBe(true); }); it('returns false if request has a kbn-version header', () => { - const request = requestFixture(); - request.raw.req.headers['kbn-version'] = 'something'; - + const request = requestFixture({ headers: { 'kbn-version': 'something' } }); expect(canRedirectRequest(request)).toBe(false); }); it('returns false if request has a kbn-xsrf header', () => { - const request = requestFixture(); - request.raw.req.headers['kbn-xsrf'] = 'something'; + const request = requestFixture({ headers: { 'kbn-xsrf': 'something' } }); expect(canRedirectRequest(request)).toBe(false); }); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts index c87d31f8ff0c24..7e2b2e5eaf9f53 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'hapi'; -import { contains, get, has } from 'lodash'; +import { KibanaRequest } from '../../../../../src/core/server'; const ROUTE_TAG_API = 'api'; const KIBANA_XSRF_HEADER = 'kbn-xsrf'; @@ -16,11 +15,12 @@ const KIBANA_VERSION_HEADER = 'kbn-version'; * only for non-AJAX and non-API requests. * @param request HapiJS request instance to check redirection possibility for. */ -export function canRedirectRequest(request: Request) { - const hasVersionHeader = has(request.raw.req.headers, KIBANA_VERSION_HEADER); - const hasXsrfHeader = has(request.raw.req.headers, KIBANA_XSRF_HEADER); +export function canRedirectRequest(request: KibanaRequest) { + const headers = request.headers; + const hasVersionHeader = headers.hasOwnProperty(KIBANA_VERSION_HEADER); + const hasXsrfHeader = headers.hasOwnProperty(KIBANA_XSRF_HEADER); - const isApiRoute = contains(get(request, 'route.settings.tags'), ROUTE_TAG_API); + const isApiRoute = request.route.options.tags.includes(ROUTE_TAG_API); const isAjaxRequest = hasVersionHeader || hasXsrfHeader; return !isApiRoute && !isAjaxRequest; diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts new file mode 100644 index 00000000000000..3cb968aca55f01 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -0,0 +1,297 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./authenticator'); + +import Boom from 'boom'; +import { first } from 'rxjs/operators'; +import { + AuthenticationHandler, + AuthToolkit, + ClusterClient, + KibanaRequest, +} from '../../../../../src/core/server'; +import { loggingServiceMock, coreMock } from '../../../../../src/core/server/mocks'; +import { AuthenticatedUser } from '../../common/model'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { requestFixture } from '../__fixtures__'; +import { createConfig$ } from '../config'; +import { getErrorStatusCode } from '../errors'; +import { LegacyAPI } from '../plugin'; +import { AuthenticationResult } from './authentication_result'; +import { setupAuthentication, SetupAuthenticationParams } from '.'; + +function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> = {}) { + return { + isEnabled: jest.fn().mockReturnValue(isEnabled), + isAvailable: jest.fn().mockReturnValue(true), + registerLicenseCheckResultsGenerator: jest.fn(), + getLicenseCheckResults: jest.fn(), + }; +} + +describe('setupAuthentication()', () => { + let mockSetupAuthenticationParams: jest.Mocked< + Pick< + SetupAuthenticationParams, + Exclude + > + > & { + core: ReturnType; + clusterClient: jest.Mocked>; + }; + let mockXpackInfo: jest.Mocked; + beforeEach(async () => { + const mockCoreSetup = coreMock.createSetup(); + mockCoreSetup.http.registerAuth.mockResolvedValue({ + sessionStorageFactory: { + asScoped: jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn(), clear: jest.fn() }), + }, + }); + + mockXpackInfo = { + isAvailable: jest.fn().mockReturnValue(true), + feature: jest.fn().mockReturnValue(mockXPackFeature()), + }; + + const mockConfig$ = createConfig$( + coreMock.createPluginInitializerContext({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + authc: { providers: ['basic'] }, + }), + true + ); + mockSetupAuthenticationParams = { + core: mockCoreSetup, + config: await mockConfig$.pipe(first()).toPromise(), + clusterClient: { callAsInternalUser: jest.fn(), asScoped: jest.fn(), close: jest.fn() }, + loggers: loggingServiceMock.create(), + getLegacyAPI: jest.fn().mockReturnValue({ xpackInfo: mockXpackInfo }), + }; + }); + + afterEach(() => jest.clearAllMocks()); + + it('properly registers auth handler', async () => { + const config = { + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + authc: { providers: ['basic'] }, + }; + + await setupAuthentication(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledWith( + expect.any(Function), + { + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + validate: expect.any(Function), + } + ); + }); + + describe('authentication handler', () => { + let authHandler: AuthenticationHandler; + let authenticate: jest.SpyInstance, [KibanaRequest]>; + let mockAuthToolkit: jest.Mocked; + beforeEach(async () => { + mockAuthToolkit = { + authenticated: jest.fn(), + redirected: jest.fn(), + rejected: jest.fn(), + }; + + await setupAuthentication(mockSetupAuthenticationParams); + + authHandler = mockSetupAuthenticationParams.core.http.registerAuth.mock.calls[0][0]; + authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] + .authenticate; + }); + + it('replies with no credentials when security is disabled in elasticsearch', async () => { + const mockRequest = requestFixture(); + + mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + + await authHandler(mockRequest, mockAuthToolkit); + + expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.rejected).not.toHaveBeenCalled(); + + expect(authenticate).not.toHaveBeenCalled(); + }); + + it('continues request with credentials on success', async () => { + const mockRequest = requestFixture(); + const mockUser = mockAuthenticatedUser(); + const mockAuthHeaders = { authorization: 'Basic xxx' }; + + authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { authHeaders: mockAuthHeaders }) + ); + + await authHandler(mockRequest, mockAuthToolkit); + + expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({ + state: mockUser, + headers: mockAuthHeaders, + }); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.rejected).not.toHaveBeenCalled(); + + expect(authenticate).toHaveBeenCalledTimes(1); + expect(authenticate).toHaveBeenCalledWith(mockRequest); + }); + + it('redirects user if redirection is requested by the authenticator', async () => { + authenticate.mockResolvedValue(AuthenticationResult.redirectTo('/some/url')); + + await authHandler(requestFixture(), mockAuthToolkit); + + expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.redirected).toHaveBeenCalledWith('/some/url'); + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.rejected).not.toHaveBeenCalled(); + }); + + it('rejects with `Internal Server Error` when `authenticate` throws unhandled exception', async () => { + authenticate.mockRejectedValue(new Error('something went wrong')); + + await authHandler(requestFixture(), mockAuthToolkit); + + expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); + const [[error]] = mockAuthToolkit.rejected.mock.calls; + expect(error.message).toBe('something went wrong'); + expect(getErrorStatusCode(error)).toBe(500); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + }); + + it('rejects with wrapped original error when `authenticate` fails to authenticate user', async () => { + const esError = Boom.badRequest('some message'); + authenticate.mockResolvedValue(AuthenticationResult.failed(esError)); + + await authHandler(requestFixture(), mockAuthToolkit); + + expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); + const [[error]] = mockAuthToolkit.rejected.mock.calls; + expect(error).toBe(esError); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + }); + + it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => { + const originalEsError = Boom.unauthorized('some message'); + originalEsError.output.headers['WWW-Authenticate'] = [ + 'Basic realm="Access to prod", charset="UTF-8"', + 'Basic', + 'Negotiate', + ] as any; + authenticate.mockResolvedValue(AuthenticationResult.failed(originalEsError, ['Negotiate'])); + + await authHandler(requestFixture(), mockAuthToolkit); + + expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); + const [[error]] = mockAuthToolkit.rejected.mock.calls; + expect(error.message).toBe(originalEsError.message); + expect((error as Boom).output.headers).toEqual({ 'WWW-Authenticate': ['Negotiate'] }); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + }); + + it('returns `unauthorized` when authentication can not be handled', async () => { + authenticate.mockResolvedValue(AuthenticationResult.notHandled()); + + await authHandler(requestFixture(), mockAuthToolkit); + + expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); + const [[error]] = mockAuthToolkit.rejected.mock.calls; + expect(error.message).toBe('Unauthorized'); + expect(getErrorStatusCode(error)).toBe(401); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentUser()', () => { + let getCurrentUser: (r: KibanaRequest) => Promise; + beforeEach(async () => { + getCurrentUser = (await setupAuthentication(mockSetupAuthenticationParams)).getCurrentUser; + }); + + it('returns `null` if Security is disabled', async () => { + mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + + await expect(getCurrentUser(requestFixture())).resolves.toBe(null); + }); + + it('fails if `authenticate` call fails', async () => { + const failureReason = new Error('Something went wrong'); + mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ + callAsCurrentUser: jest.fn().mockRejectedValue(failureReason as any), + } as any); + await expect(getCurrentUser(requestFixture())).rejects.toBe(failureReason); + }); + + it('returns result of `authenticate` call.', async () => { + const mockUser = mockAuthenticatedUser(); + mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ + callAsCurrentUser: jest.fn().mockResolvedValue(mockUser as any), + } as any); + await expect(getCurrentUser(requestFixture())).resolves.toBe(mockUser); + }); + }); + + describe('isAuthenticated()', () => { + let isAuthenticated: (r: KibanaRequest) => Promise; + beforeEach(async () => { + isAuthenticated = (await setupAuthentication(mockSetupAuthenticationParams)).isAuthenticated; + }); + + it('returns `true` if Security is disabled', async () => { + mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + + await expect(isAuthenticated(requestFixture())).resolves.toBe(true); + }); + + it('returns `true` if `authenticate` succeeds.', async () => { + const mockUser = mockAuthenticatedUser(); + mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ + callAsCurrentUser: jest.fn().mockResolvedValue(mockUser as any), + } as any); + await expect(isAuthenticated(requestFixture())).resolves.toBe(true); + }); + + it('returns `false` if `authenticate` fails with 401.', async () => { + const failureReason = Boom.unauthorized(); + mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ + callAsCurrentUser: jest.fn().mockRejectedValue(failureReason as any), + } as any); + await expect(isAuthenticated(requestFixture())).resolves.toBe(false); + }); + + it('fails if `authenticate` call fails with unknown reason', async () => { + const failureReason = Boom.badRequest(); + mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ + callAsCurrentUser: jest.fn().mockRejectedValue(failureReason as any), + } as any); + await expect(isAuthenticated(requestFixture())).rejects.toBe(failureReason); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 7903822082880e..73d6735a0f7f94 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -4,7 +4,146 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; +import { + ClusterClient, + CoreSetup, + KibanaRequest, + LoggerFactory, + SessionStorageFactory, +} from '../../../../../src/core/server'; +import { AuthenticatedUser } from '../../common/model'; +import { ConfigType } from '../config'; +import { getErrorStatusCode, wrapError } from '../errors'; +import { Authenticator, ProviderSession } from './authenticator'; +import { LegacyAPI } from '../plugin'; + export { canRedirectRequest } from './can_redirect_request'; +export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; export { BasicCredentials } from './providers'; + +export interface SetupAuthenticationParams { + core: CoreSetup; + clusterClient: PublicMethodsOf; + config: ConfigType; + loggers: LoggerFactory; + getLegacyAPI(): LegacyAPI; +} + +export async function setupAuthentication({ + core, + clusterClient, + config, + loggers, + getLegacyAPI, +}: SetupAuthenticationParams) { + const authLogger = loggers.get('authentication'); + + const isSecurityFeatureDisabled = () => { + const xpackInfo = getLegacyAPI().xpackInfo; + return xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled(); + }; + + /** + * Retrieves currently authenticated user associated with the specified request. + * @param request + */ + const getCurrentUser = async (request: KibanaRequest) => { + if (isSecurityFeatureDisabled()) { + return null; + } + + return (await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.authenticate')) as AuthenticatedUser; + }; + + const { sessionStorageFactory } = (await core.http.registerAuth( + async (request, t) => { + if (!authenticator) { + throw new Error('Authenticator is not initialized!'); + } + + // If security is disabled continue with no user credentials and delete the client cookie as well. + if (isSecurityFeatureDisabled()) { + return t.authenticated(); + } + + let authenticationResult; + try { + authenticationResult = await authenticator.authenticate(request); + } catch (err) { + authLogger.error(err); + return t.rejected(wrapError(err)); + } + + if (authenticationResult.succeeded()) { + return t.authenticated({ + state: authenticationResult.user, + headers: authenticationResult.authHeaders, + }); + } + + if (authenticationResult.redirected()) { + // Some authentication mechanisms may require user to be redirected to another location to + // initiate or complete authentication flow. It can be Kibana own login page for basic + // authentication (username and password) or arbitrary external page managed by 3rd party + // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who + // decides what location user should be redirected to. + return t.redirected(authenticationResult.redirectURL!); + } + + if (authenticationResult.failed()) { + authLogger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); + + const error = wrapError(authenticationResult.error); + if (authenticationResult.challenges) { + error.output.headers['WWW-Authenticate'] = authenticationResult.challenges as any; + } + + return t.rejected(error); + } + + return t.rejected(Boom.unauthorized()); + }, + { + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + validate: (sessionValue: ProviderSession) => + !(sessionValue.expires && sessionValue.expires < Date.now()), + } + )) as { sessionStorageFactory: SessionStorageFactory }; + + authLogger.debug('Successfully registered core authentication handler.'); + + const authenticator = new Authenticator({ + clusterClient, + basePath: core.http.basePath, + config: { sessionTimeout: config.sessionTimeout, authc: config.authc }, + isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request), + loggers, + sessionStorageFactory, + }); + + return { + login: authenticator.login.bind(authenticator), + logout: authenticator.logout.bind(authenticator), + getCurrentUser, + isAuthenticated: async (request: KibanaRequest) => { + try { + await getCurrentUser(request); + } catch (err) { + // Don't swallow server errors. + if (getErrorStatusCode(err) !== 401) { + throw err; + } + return false; + } + + return true; + }, + }; +} diff --git a/x-pack/plugins/security/server/authentication/login_attempt.test.ts b/x-pack/plugins/security/server/authentication/login_attempt.test.ts deleted file mode 100644 index ba3f29ddd491f4..00000000000000 --- a/x-pack/plugins/security/server/authentication/login_attempt.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { LoginAttempt } from './login_attempt'; - -describe('LoginAttempt', () => { - describe('getCredentials()', () => { - it('returns null by default', () => { - const attempt = new LoginAttempt(); - expect(attempt.getCredentials()).toBe(null); - }); - - it('returns a credentials object after credentials are set', () => { - const attempt = new LoginAttempt(); - attempt.setCredentials('foo', 'bar'); - expect(attempt.getCredentials()).toEqual({ username: 'foo', password: 'bar' }); - }); - }); - - describe('setCredentials()', () => { - it('sets the credentials for this login attempt', () => { - const attempt = new LoginAttempt(); - attempt.setCredentials('foo', 'bar'); - expect(attempt.getCredentials()).toEqual({ username: 'foo', password: 'bar' }); - }); - - it('throws if credentials have already been set', () => { - const attempt = new LoginAttempt(); - attempt.setCredentials('foo', 'bar'); - expect(() => attempt.setCredentials('some', 'some')).toThrowError( - 'Credentials for login attempt have already been set' - ); - }); - }); -}); diff --git a/x-pack/plugins/security/server/authentication/login_attempt.ts b/x-pack/plugins/security/server/authentication/login_attempt.ts deleted file mode 100644 index 642a3cd1f29347..00000000000000 --- a/x-pack/plugins/security/server/authentication/login_attempt.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * Represents login credentials. - */ -interface LoginCredentials { - username: string; - password: string; -} - -/** - * A LoginAttempt represents a single attempt to provide login credentials. - * Once credentials are set, they cannot be changed. - */ -export class LoginAttempt { - /** - * Username and password for login. - */ - private credentials: LoginCredentials | null = null; - - /** - * Gets the username and password for this login. - */ - public getCredentials() { - return this.credentials; - } - - /** - * Sets the username and password for this login. - */ - public setCredentials(username: string, password: string) { - if (this.credentials) { - throw new Error('Credentials for login attempt have already been set'); - } - - this.credentials = Object.freeze({ username, password }); - } -} diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index a9132258c75f17..8e7410ddec0772 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -4,21 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { stub, createStubInstance } from 'sinon'; +import sinon from 'sinon'; +import { ScopedClusterClient } from '../../../../../../src/core/server'; import { Tokens } from '../tokens'; -import { AuthenticationProviderOptions } from './base'; +import { loggingServiceMock, httpServiceMock } from '../../../../../../src/core/server/mocks'; -export function mockAuthenticationProviderOptions( - providerOptions: Partial> = {} +export type MockAuthenticationProviderOptions = ReturnType< + typeof mockAuthenticationProviderOptions +>; + +export function mockScopedClusterClient( + client: MockAuthenticationProviderOptions['client'], + requestMatcher: sinon.SinonMatcher = sinon.match.any ) { - const client = { callWithRequest: stub(), callWithInternalUser: stub() }; - const log = stub(); + const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient); + client.asScoped.withArgs(requestMatcher).returns(scopedClusterClient); + return scopedClusterClient; +} + +export function mockAuthenticationProviderOptions() { + const logger = loggingServiceMock.create().get(); + const basePath = httpServiceMock.createSetupContract().basePath; + basePath.get.mockReturnValue('/base-path'); return { - client, - log, - basePath: '/base-path', - tokens: createStubInstance(Tokens), - ...providerOptions, + client: { callAsInternalUser: sinon.stub(), asScoped: sinon.stub(), close: sinon.stub() }, + logger, + basePath, + tokens: sinon.createStubInstance(Tokens), }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index af4028185381c2..940cba016d1ac0 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -4,26 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { + KibanaRequest, + Logger, + HttpServiceSetup, + ClusterClient, + Headers, +} from '../../../../../../src/core/server'; +import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { LoginAttempt } from '../login_attempt'; import { Tokens } from '../tokens'; -/** - * Describes a request complemented with `loginAttempt` method. - */ -export interface RequestWithLoginAttempt extends Legacy.Request { - loginAttempt: () => LoginAttempt; -} - /** * Represents available provider options. */ export interface AuthenticationProviderOptions { - basePath: string; - client: Legacy.Plugins.elasticsearch.Cluster; - log: (tags: string[], message: string) => void; + basePath: HttpServiceSetup['basePath']; + client: PublicMethodsOf; + logger: Logger; tokens: PublicMethodsOf; } @@ -36,29 +35,58 @@ export type AuthenticationProviderSpecificOptions = Record; * Base class that all authentication providers should extend. */ export abstract class BaseAuthenticationProvider { + /** + * Logger instance bound to a specific provider context. + */ + protected readonly logger: Logger; + /** * Instantiates AuthenticationProvider. * @param options Provider options object. */ - constructor(protected readonly options: Readonly) {} + constructor(protected readonly options: Readonly) { + this.logger = options.logger; + } /** - * Performs request authentication. + * Performs initial login request and creates user session. Provider isn't required to implement + * this method if it doesn't support initial login request. * @param request Request instance. + * @param loginAttempt Login attempt associated with the provider. * @param [state] Optional state object associated with the provider. */ - abstract authenticate( - request: RequestWithLoginAttempt, + async login( + request: KibanaRequest, + loginAttempt: unknown, state?: unknown - ): Promise; + ): Promise { + return AuthenticationResult.notHandled(); + } + + /** + * Performs request authentication based on the session created during login or other information + * associated with the request (e.g. `Authorization` HTTP header). + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + abstract authenticate(request: KibanaRequest, state?: unknown): Promise; /** * Invalidates user session associated with the request. * @param request Request instance. * @param [state] Optional state object associated with the provider that needs to be invalidated. */ - abstract deauthenticate( - request: Legacy.Request, - state?: unknown - ): Promise; + abstract logout(request: KibanaRequest, state?: unknown): Promise; + + /** + * Queries Elasticsearch `_authenticate` endpoint to authenticate request and retrieve the user + * information of authenticated user. + * @param request Request instance. + * @param [authHeaders] Optional `Headers` dictionary to send with the request. + */ + protected async getUser(request: KibanaRequest, authHeaders: Headers = {}) { + return (await this.options.client + .asScoped({ headers: { ...request.headers, ...authHeaders } }) + .callAsCurrentUser('shield.authenticate')) as AuthenticatedUser; + } } diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 88ae1d76f5b579..c68a49ed61a347 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -5,29 +5,71 @@ */ import sinon from 'sinon'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; -import { mockAuthenticationProviderOptions } from './base.mock'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { requestFixture } from '../../__fixtures__'; +import { mockAuthenticationProviderOptions, mockScopedClusterClient } from './base.mock'; import { BasicAuthenticationProvider, BasicCredentials } from './basic'; function generateAuthorizationHeader(username: string, password: string) { const { headers: { authorization }, - } = BasicCredentials.decorateRequest(requestFixture(), username, password); + } = BasicCredentials.decorateRequest( + { headers: {} as Record }, + username, + password + ); - return authorization; + return authorization as string; } describe('BasicAuthenticationProvider', () => { - describe('`authenticate` method', () => { - let provider: BasicAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - beforeEach(() => { - const providerOptions = mockAuthenticationProviderOptions(); - callWithRequest = providerOptions.client.callWithRequest; - provider = new BasicAuthenticationProvider(providerOptions); + let provider: BasicAuthenticationProvider; + let mockOptions: ReturnType; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions(); + provider = new BasicAuthenticationProvider(mockOptions); + }); + + describe('`login` method', () => { + it('succeeds with valid login attempt, creates session and authHeaders', async () => { + const request = requestFixture(); + const user = mockAuthenticatedUser(); + const credentials = { username: 'user', password: 'password' }; + const authorization = generateAuthorizationHeader(credentials.username, credentials.password); + + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + const authenticationResult = await provider.login(request, credentials); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + expect(authenticationResult.state).toEqual({ authorization }); + expect(authenticationResult.authHeaders).toEqual({ authorization }); + }); + + it('fails if user cannot be retrieved during login attempt', async () => { + const request = requestFixture(); + const credentials = { username: 'user', password: 'password' }; + const authorization = generateAuthorizationHeader(credentials.username, credentials.password); + + const authenticationError = new Error('Some error'); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(authenticationError); + + const authenticationResult = await provider.login(request, credentials); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.user).toBeUndefined(); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.error).toEqual(authenticationError); }); + }); + describe('`authenticate` method', () => { it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => { // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and // avoid triggering of redirect logic. @@ -41,16 +83,13 @@ describe('BasicAuthenticationProvider', () => { it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { const authenticationResult = await provider.authenticate( - requestFixture({ - path: '/some-path # that needs to be encoded', - basePath: '/s/foo', - }), + requestFixture({ path: '/s/foo/some-path # that needs to be encoded' }), null ); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe( - '/base-path/login?next=%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' ); }); @@ -59,57 +98,33 @@ describe('BasicAuthenticationProvider', () => { expect(authenticationResult.notHandled()).toBe(true); }); - it('succeeds with valid login attempt and stores in session', async () => { - const user = { username: 'user' }; - const authorization = generateAuthorizationHeader('user', 'password'); - const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toEqual({ authorization }); - sinon.assert.calledOnce(callWithRequest); - }); - it('succeeds if only `authorization` header is available.', async () => { - const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password'); - const user = { username: 'user' }; + const request = requestFixture({ + headers: { authorization: generateAuthorizationHeader('user', 'password') }, + }); + const user = mockAuthenticatedUser(); - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: request.headers })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); - sinon.assert.calledOnce(callWithRequest); - }); - - it('does not return session state for header-based auth', async () => { - const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password'); - const user = { username: 'user' }; - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.state).not.toEqual({ - authorization: request.headers.authorization, - }); + // Session state and authHeaders aren't returned for header-based auth. + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); }); it('succeeds if only state is available.', async () => { const request = requestFixture(); - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const authorization = generateAuthorizationHeader('user', 'password'); - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); const authenticationResult = await provider.authenticate(request, { authorization }); @@ -117,7 +132,7 @@ describe('BasicAuthenticationProvider', () => { expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toBeUndefined(); - sinon.assert.calledOnce(callWithRequest); + expect(authenticationResult.authHeaders).toEqual({ authorization }); }); it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { @@ -126,7 +141,7 @@ describe('BasicAuthenticationProvider', () => { const authenticationResult = await provider.authenticate(request, { authorization }); - sinon.assert.notCalled(callWithRequest); + sinon.assert.notCalled(mockOptions.client.asScoped); expect(request.headers.authorization).toBe('Bearer ***'); expect(authenticationResult.notHandled()).toBe(true); }); @@ -136,8 +151,8 @@ describe('BasicAuthenticationProvider', () => { const authorization = generateAuthorizationHeader('user', 'password'); const authenticationError = new Error('Forbidden'); - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(authenticationError); const authenticationResult = await provider.authenticate(request, { authorization }); @@ -146,45 +161,43 @@ describe('BasicAuthenticationProvider', () => { expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.error).toBe(authenticationError); - sinon.assert.calledOnce(callWithRequest); }); it('authenticates only via `authorization` header even if state is available.', async () => { - const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password'); - const user = { username: 'user' }; - const authorization = generateAuthorizationHeader('user1', 'password2'); + const request = requestFixture({ + headers: { authorization: generateAuthorizationHeader('user', 'password') }, + }); + const user = mockAuthenticatedUser(); - // GetUser will be called with request's `authorization` header. - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: request.headers })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); - const authenticationResult = await provider.authenticate(request, { authorization }); + const authorizationInState = generateAuthorizationHeader('user1', 'password2'); + const authenticationResult = await provider.authenticate(request, { + authorization: authorizationInState, + }); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).not.toEqual({ - authorization: request.headers.authorization, - }); - sinon.assert.calledOnce(callWithRequest); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); }); }); - describe('`deauthenticate` method', () => { - let provider: BasicAuthenticationProvider; - beforeEach(() => { - provider = new BasicAuthenticationProvider(mockAuthenticationProviderOptions()); - }); - + describe('`logout` method', () => { it('always redirects to the login page.', async () => { const request = requestFixture(); - const deauthenticateResult = await provider.deauthenticate(request); + const deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.redirected()).toBe(true); expect(deauthenticateResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); }); it('passes query string parameters to the login page.', async () => { const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' }); - const deauthenticateResult = await provider.deauthenticate(request); + const deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.redirected()).toBe(true); expect(deauthenticateResult.redirectURL).toBe( '/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' @@ -215,8 +228,8 @@ describe('BasicAuthenticationProvider', () => { }); it('`decorateRequest` correctly sets authorization header.', () => { - const oneRequest = requestFixture(); - const anotherRequest = requestFixture({ headers: { authorization: 'Basic ***' } }); + const oneRequest = { headers: {} as Record }; + const anotherRequest = { headers: { authorization: 'Basic ***' } }; BasicCredentials.decorateRequest(oneRequest, 'one-user', 'one-password'); BasicCredentials.decorateRequest(anotherRequest, 'another-user', 'another-password'); diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index bac711b8680712..8dcd783e7bd36e 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -6,11 +6,11 @@ /* eslint-disable max-classes-per-file */ -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; +import { FakeRequest, KibanaRequest } from '../../../../../../src/core/server'; +import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; +import { BaseAuthenticationProvider } from './base'; /** * Utility class that knows how to decorate request with proper Basic authentication headers. @@ -23,7 +23,7 @@ export class BasicCredentials { * @param username User name. * @param password User password. */ - public static decorateRequest( + public static decorateRequest( request: T, username: string, password: string @@ -47,6 +47,14 @@ export class BasicCredentials { } } +/** + * Describes the parameters that are required by the provider to process the initial login request. + */ +interface ProviderLoginAttempt { + username: string; + password: string; +} + /** * The state supported by the provider. */ @@ -63,36 +71,63 @@ interface ProviderState { * Provider that supports request authentication via Basic HTTP Authentication. */ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Performs initial login request using username and password. + * @param request Request instance. + * @param attempt User credentials. + * @param [state] Optional state object associated with the provider. + */ + public async login( + request: KibanaRequest, + { username, password }: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.logger.debug('Trying to perform a login.'); + + try { + const { headers: authHeaders } = BasicCredentials.decorateRequest( + { headers: {} }, + username, + password + ); + + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: authHeaders }); + } catch (err) { + this.logger.debug(`Failed to perform a login: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + /** * Performs request authentication using Basic HTTP Authentication. * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); - - // first try from login payload - let authenticationResult = await this.authenticateViaLoginAttempt(request); - - // if there isn't a payload, try header-based auth - if (authenticationResult.notHandled()) { - const { - authenticationResult: headerAuthResult, - headerNotRecognized, - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return headerAuthResult; - } - authenticationResult = headerAuthResult; + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); + + // try header-based auth + const { + authenticationResult: headerAuthResult, + headerNotRecognized, + } = await this.authenticateViaHeader(request); + if (headerNotRecognized) { + return headerAuthResult; } + let authenticationResult = headerAuthResult; if (authenticationResult.notHandled() && state) { authenticationResult = await this.authenticateViaState(request, state); } else if (authenticationResult.notHandled() && canRedirectRequest(request)) { // If we couldn't handle authentication let's redirect user to the login page. - const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`); + const nextURL = encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + ); authenticationResult = AuthenticationResult.redirectTo( - `${this.options.basePath}/login?next=${nextURL}` + `${this.options.basePath.get(request)}/login?next=${nextURL}` ); } @@ -103,43 +138,13 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * Redirects user to the login page preserving query string parameters. * @param request Request instance. */ - public async deauthenticate(request: Legacy.Request) { + public async logout(request: KibanaRequest) { // Query string may contain the path where logout has been called or // logout reason that login page may need to know. const queryString = request.url.search || `?msg=LOGGED_OUT`; - return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`); - } - - /** - * Validates whether request contains a login payload and authenticates the - * user if necessary. - * @param request Request instance. - */ - private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via login attempt.'); - - const credentials = request.loginAttempt().getCredentials(); - if (!credentials) { - this.debug('Username and password not found in payload.'); - return AuthenticationResult.notHandled(); - } - - try { - const { username, password } = credentials; - BasicCredentials.decorateRequest(request, username, password); - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request has been authenticated via login attempt.'); - return AuthenticationResult.succeeded(user, { authorization: request.headers.authorization }); - } catch (err) { - this.debug(`Failed to authenticate request via login attempt: ${err.message}`); - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - return AuthenticationResult.failed(err); - } + return DeauthenticationResult.redirectTo( + `${this.options.basePath.get(request)}/login${queryString}` + ); } /** @@ -147,18 +152,18 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'basic') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -166,13 +171,12 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via header.'); + const user = await this.getUser(request); + this.logger.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } @@ -183,44 +187,23 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { authorization }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { authorization }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); if (!authorization) { - this.debug('Access token is not found in state.'); + this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } - request.headers.authorization = authorization; - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via state.'); + const authHeaders = { authorization }; + const user = await this.getUser(request, authHeaders); - return AuthenticationResult.succeeded(user); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.debug(`Failed to authenticate request via state: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to crash if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } - - /** - * Logs message with `debug` level and saml/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'basic'], message); - } } diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index a3a0c6192baa42..a1b71e5106fd59 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -7,10 +7,10 @@ export { BaseAuthenticationProvider, AuthenticationProviderOptions, - RequestWithLoginAttempt, + AuthenticationProviderSpecificOptions, } from './base'; export { BasicAuthenticationProvider, BasicCredentials } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; -export { SAMLAuthenticationProvider } from './saml'; +export { SAMLAuthenticationProvider, isSAMLRequestQuery } from './saml'; export { TokenAuthenticationProvider } from './token'; export { OIDCAuthenticationProvider } from './oidc'; diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index bbfa1b9f75d0e4..0cab25a12c59ab 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -6,36 +6,26 @@ import Boom from 'boom'; import sinon from 'sinon'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; -import { mockAuthenticationProviderOptions } from './base.mock'; +import { requestFixture } from '../../__fixtures__'; +import { + MockAuthenticationProviderOptions, + mockAuthenticationProviderOptions, + mockScopedClusterClient, +} from './base.mock'; import { KerberosAuthenticationProvider } from './kerberos'; describe('KerberosAuthenticationProvider', () => { let provider: KerberosAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - let callWithInternalUser: sinon.SinonStub; - let tokens: ReturnType['tokens']; + let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - const providerOptions = mockAuthenticationProviderOptions(); - callWithRequest = providerOptions.client.callWithRequest; - callWithInternalUser = providerOptions.client.callWithInternalUser; - tokens = providerOptions.tokens; - - provider = new KerberosAuthenticationProvider(providerOptions); + mockOptions = mockAuthenticationProviderOptions(); + provider = new KerberosAuthenticationProvider(mockOptions); }); describe('`authenticate` method', () => { - it('does not handle AJAX request that can not be authenticated.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); - - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.notHandled()).toBe(true); - }); - it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } }); const tokenPair = { @@ -45,31 +35,17 @@ describe('KerberosAuthenticationProvider', () => { const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.notCalled(callWithRequest); + sinon.assert.notCalled(mockOptions.client.asScoped); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); expect(request.headers.authorization).toBe('Basic some:credentials'); expect(authenticationResult.notHandled()).toBe(true); }); - it('does not handle requests with non-empty `loginAttempt`.', async () => { - const request = requestFixture(); - const tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.notCalled(callWithRequest); - expect(authenticationResult.notHandled()).toBe(true); - }); - it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { const request = requestFixture(); - callWithRequest.withArgs(request, 'shield.authenticate').resolves({}); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves({}); const authenticationResult = await provider.authenticate(request, null); @@ -78,12 +54,15 @@ describe('KerberosAuthenticationProvider', () => { it('does not handle requests if backend does not support Kerberos.', async () => { const request = requestFixture(); - callWithRequest.withArgs(request, 'shield.authenticate').rejects(Boom.unauthorized()); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(Boom.unauthorized()); + let authenticationResult = await provider.authenticate(request, null); expect(authenticationResult.notHandled()).toBe(true); - callWithRequest - .withArgs(request, 'shield.authenticate') + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.unauthorized(null, 'Basic')); authenticationResult = await provider.authenticate(request, null); expect(authenticationResult.notHandled()).toBe(true); @@ -93,16 +72,18 @@ describe('KerberosAuthenticationProvider', () => { const request = requestFixture(); const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(Boom.unauthorized()); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(Boom.unauthorized()); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); let authenticationResult = await provider.authenticate(request, tokenPair); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); expect(authenticationResult.challenges).toBeUndefined(); - callWithRequest - .withArgs(request, 'shield.authenticate') + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.unauthorized(null, 'Basic')); authenticationResult = await provider.authenticate(request, tokenPair); @@ -113,8 +94,8 @@ describe('KerberosAuthenticationProvider', () => { it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = requestFixture(); - callWithRequest - .withArgs(request, 'shield.authenticate') + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.unauthorized(null, 'Negotiate')); const authenticationResult = await provider.authenticate(request, null); @@ -126,7 +107,9 @@ describe('KerberosAuthenticationProvider', () => { it('fails if request authentication is failed with non-401 error.', async () => { const request = requestFixture(); - callWithRequest.withArgs(request, 'shield.authenticate').rejects(Boom.serverUnavailable()); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(Boom.serverUnavailable()); const authenticationResult = await provider.authenticate(request, null); @@ -136,29 +119,36 @@ describe('KerberosAuthenticationProvider', () => { }); it('gets an token pair in exchange to SPNEGO one and stores it in the state.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer some-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(Boom.serverUnavailable()); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); - callWithInternalUser + mockOptions.client.callAsInternalUser .withArgs('shield.getAccessToken') .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.getAccessToken', { - body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, - }); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.getAccessToken', + { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + ); - expect(request.headers.authorization).toBe('Bearer some-token'); + expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toBe(user); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer some-token' }); expect(authenticationResult.state).toEqual({ accessToken: 'some-token', refreshToken: 'some-refresh-token', @@ -169,13 +159,17 @@ describe('KerberosAuthenticationProvider', () => { const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); const failureReason = Boom.unauthorized(); - callWithInternalUser.withArgs('shield.getAccessToken').rejects(failureReason); + mockOptions.client.callAsInternalUser + .withArgs('shield.getAccessToken') + .rejects(failureReason); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.getAccessToken', { - body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, - }); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.getAccessToken', + { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + ); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.failed()).toBe(true); @@ -187,22 +181,24 @@ describe('KerberosAuthenticationProvider', () => { const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); const failureReason = Boom.unauthorized(); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer some-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(failureReason); - callWithInternalUser + mockOptions.client.callAsInternalUser .withArgs('shield.getAccessToken') .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.getAccessToken', { - body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, - }); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.getAccessToken', + { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + ); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.failed()).toBe(true); @@ -211,55 +207,59 @@ describe('KerberosAuthenticationProvider', () => { }); it('succeeds if state contains a valid token.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', }; - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + const authorization = `Bearer ${tokenPair.accessToken}`; + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request, tokenPair); - expect(request.headers.authorization).toBe('Bearer some-valid-token'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toEqual({ authorization }); expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBeUndefined(); }); it('succeeds with valid session even if requiring a token refresh', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.unauthorized()); - tokens.refresh + mockOptions.tokens.refresh .withArgs(tokenPair.refreshToken) .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer newfoo' } }), - 'shield.authenticate' - ) - .returns(user); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer newfoo' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledTwice(callWithRequest); - sinon.assert.calledOnce(tokens.refresh); + sinon.assert.calledOnce(mockOptions.tokens.refresh); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' }); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' }); - expect(request.headers.authorization).toEqual('Bearer newfoo'); + expect(request.headers).not.toHaveProperty('authorization'); }); it('fails if token from the state is rejected because of unknown reason.', async () => { @@ -270,22 +270,28 @@ describe('KerberosAuthenticationProvider', () => { }; const failureReason = Boom.internal('Token is not valid!'); - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); + const scopedClusterClient = mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ); + scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').rejects(failureReason); const authenticationResult = await provider.authenticate(request, tokenPair); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); - sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken'); + sinon.assert.neverCalledWith(scopedClusterClient.callAsCurrentUser, 'shield.getAccessToken'); }); it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => { const request = requestFixture(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; - callWithRequest.rejects(Boom.unauthorized(null, 'Negotiate')); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(Boom.unauthorized(null, 'Negotiate')); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -298,19 +304,21 @@ describe('KerberosAuthenticationProvider', () => { const request = requestFixture({ headers: {} }); const tokenPair = { accessToken: 'missing-token', refreshToken: 'missing-refresh-token' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 500, body: { error: { reason: 'token document is missing and must be present' } }, - }) - .withArgs(sinon.match({ headers: {} }), 'shield.authenticate') + }); + + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: {} })) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.unauthorized(null, 'Negotiate')); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -320,15 +328,21 @@ describe('KerberosAuthenticationProvider', () => { }); it('succeeds if `authorization` contains a valid token.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } }); - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request); expect(request.headers.authorization).toBe('Bearer some-valid-token'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBeUndefined(); }); @@ -337,7 +351,12 @@ describe('KerberosAuthenticationProvider', () => { const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); const failureReason = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); const authenticationResult = await provider.authenticate(request); @@ -346,7 +365,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); const tokenPair = { accessToken: 'some-valid-token', @@ -354,10 +373,18 @@ describe('KerberosAuthenticationProvider', () => { }; const failureReason = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); - callWithRequest - .withArgs(sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -367,17 +394,17 @@ describe('KerberosAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented.', async () => { const request = requestFixture(); - let deauthenticateResult = await provider.deauthenticate(request); + let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, null); + deauthenticateResult = await provider.logout(request, null); expect(deauthenticateResult.notHandled()).toBe(true); - sinon.assert.notCalled(tokens.invalidate); + sinon.assert.notCalled(mockOptions.tokens.invalidate); }); it('fails if `tokens.invalidate` fails', async () => { @@ -385,12 +412,12 @@ describe('KerberosAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - tokens.invalidate.withArgs(tokenPair).rejects(failureReason); + mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -403,12 +430,12 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - tokens.invalidate.withArgs(tokenPair).resolves(); + mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/logged_out'); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index af6122e06e2515..73192b611284a2 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -6,12 +6,12 @@ import Boom from 'boom'; import { get } from 'lodash'; -import { Legacy } from 'kibana'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { BaseAuthenticationProvider } from './base'; import { Tokens, TokenPair } from '../tokens'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; /** * The state supported by the provider. @@ -22,9 +22,9 @@ type ProviderState = TokenPair; * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. * @param request Request instance to extract authentication scheme for. */ -function getRequestAuthenticationScheme(request: RequestWithLoginAttempt) { +function getRequestAuthenticationScheme(request: KibanaRequest) { const authorization = request.headers.authorization; - if (!authorization) { + if (!authorization || typeof authorization !== 'string') { return ''; } @@ -40,20 +40,15 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); const authenticationScheme = getRequestAuthenticationScheme(request); if ( authenticationScheme && (authenticationScheme !== 'negotiate' && authenticationScheme !== 'bearer') ) { - this.debug(`Unsupported authentication scheme: ${authenticationScheme}`); - return AuthenticationResult.notHandled(); - } - - if (request.loginAttempt().getCredentials() != null) { - this.debug('Login attempt is detected, but it is not supported by the provider'); + this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); return AuthenticationResult.notHandled(); } @@ -88,18 +83,18 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state?: ProviderState | null) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if (!state) { - this.debug('There is no access token invalidate.'); + this.logger.debug('There is no access token invalidate.'); return DeauthenticationResult.notHandled(); } try { await this.options.tokens.invalidate(state); } catch (err) { - this.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); + this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); return DeauthenticationResult.failed(err); } @@ -111,46 +106,36 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * get an access token in exchange. * @param request Request instance. */ - private async authenticateWithNegotiateScheme(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate request using "Negotiate" authentication scheme.'); + private async authenticateWithNegotiateScheme(request: KibanaRequest) { + this.logger.debug('Trying to authenticate request using "Negotiate" authentication scheme.'); - const [, kerberosTicket] = request.headers.authorization.split(/\s+/); + const [, kerberosTicket] = (request.headers.authorization as string).split(/\s+/); // First attempt to exchange SPNEGO token for an access token. let tokens: { access_token: string; refresh_token: string }; try { - tokens = await this.options.client.callWithInternalUser('shield.getAccessToken', { + tokens = await this.options.client.callAsInternalUser('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, }); } catch (err) { - this.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); + this.logger.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); return AuthenticationResult.failed(err); } - this.debug('Get token API request to Elasticsearch successful'); - - // Then attempt to query for the user details using the new token - const originalAuthorizationHeader = request.headers.authorization; - request.headers.authorization = `Bearer ${tokens.access_token}`; + this.logger.debug('Get token API request to Elasticsearch successful'); try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('User has been authenticated with new access token'); + // Then attempt to query for the user details using the new token + const authHeaders = { authorization: `Bearer ${tokens.access_token}` }; + const user = await this.getUser(request, authHeaders); + this.logger.debug('User has been authenticated with new access token'); return AuthenticationResult.succeeded(user, { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, + authHeaders, + state: { accessToken: tokens.access_token, refreshToken: tokens.refresh_token }, }); } catch (err) { - this.debug(`Failed to authenticate request via access token: ${err.message}`); - - // Restore `Authorization` header we've just set. We can end up here only if newly generated - // access token was rejected by Elasticsearch for some reason and it doesn't make any sense to - // keep it in the request object since it can confuse other consumers of the request down the - // line (e.g. in the next authentication provider). - request.headers.authorization = originalAuthorizationHeader; - + this.logger.debug(`Failed to authenticate request via access token: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -159,20 +144,18 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend. * @param request Request instance. */ - private async authenticateWithBearerScheme(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate request using "Bearer" authentication scheme.'); + private async authenticateWithBearerScheme(request: KibanaRequest) { + this.logger.debug('Trying to authenticate request using "Bearer" authentication scheme.'); try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated using "Bearer" authentication scheme.'); + const user = await this.getUser(request); + this.logger.debug('Request has been authenticated using "Bearer" authentication scheme.'); return AuthenticationResult.succeeded(user); } catch (err) { - this.debug( + this.logger.debug( `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` ); - return AuthenticationResult.failed(err); } } @@ -183,34 +166,22 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); if (!accessToken) { - this.debug('Access token is not found in state.'); + this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } - request.headers.authorization = `Bearer ${accessToken}`; - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.debug(`Failed to authenticate request via state: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -222,11 +193,8 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, - state: ProviderState - ) { - this.debug('Trying to refresh access token.'); + private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) { + this.logger.debug('Trying to refresh access token.'); let refreshedTokenPair: TokenPair | null; try { @@ -237,27 +205,22 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. if (refreshedTokenPair === null) { - this.debug('Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.'); + this.logger.debug( + 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.' + ); return this.authenticateViaSPNEGO(request, state); } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); return AuthenticationResult.failed(err); } } @@ -267,17 +230,14 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - private async authenticateViaSPNEGO( - request: RequestWithLoginAttempt, - state?: ProviderState | null - ) { - this.debug('Trying to authenticate request via SPNEGO.'); + private async authenticateViaSPNEGO(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug('Trying to authenticate request via SPNEGO.'); // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. let authenticationError: Error; try { - await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.debug('Request was not supposed to be authenticated, ignoring result.'); + await this.getUser(request); + this.logger.debug('Request was not supposed to be authenticated, ignoring result.'); return AuthenticationResult.notHandled(); } catch (err) { // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch @@ -294,11 +254,11 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { ); if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) { - this.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`); + this.logger.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`); return AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']); } - this.debug(`SPNEGO is not supported by the backend, challenges are: [${challenges}].`); + this.logger.debug(`SPNEGO is not supported by the backend, challenges are: [${challenges}].`); // If we failed to do SPNEGO and have a session with expired token that belongs to Kerberos // authentication provider then it means Elasticsearch isn't configured to use Kerberos anymore. @@ -308,12 +268,4 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { ? AuthenticationResult.failed(Boom.unauthorized()) : AuthenticationResult.notHandled(); } - - /** - * Logs message with `debug` level and kerberos/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'kerberos'], message); - } } diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 78a2eee0e54088..35127aad790949 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -6,30 +6,27 @@ import sinon from 'sinon'; import Boom from 'boom'; -import { LoginAttempt } from '../login_attempt'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { mockAuthenticationProviderOptions } from './base.mock'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; +import { requestFixture } from '../../__fixtures__'; +import { + MockAuthenticationProviderOptions, + mockAuthenticationProviderOptions, + mockScopedClusterClient, +} from './base.mock'; import { OIDCAuthenticationProvider } from './oidc'; describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - let callWithInternalUser: sinon.SinonStub; - let tokens: ReturnType['tokens']; + let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' }); - const providerSpecificOptions = { realm: 'oidc1' }; - callWithRequest = providerOptions.client.callWithRequest; - callWithInternalUser = providerOptions.client.callWithInternalUser; - tokens = providerOptions.tokens; - - provider = new OIDCAuthenticationProvider(providerOptions, providerSpecificOptions); + mockOptions = mockAuthenticationProviderOptions(); + provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); }); it('throws if `realm` option is not specified', () => { - const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' }); + const providerOptions = mockAuthenticationProviderOptions(); expect(() => new OIDCAuthenticationProvider(providerOptions)).toThrowError( 'Realm name must be specified' @@ -42,75 +39,11 @@ describe('OIDCAuthenticationProvider', () => { ); }); - describe('`authenticate` method', () => { - it('does not handle AJAX request that can not be authenticated.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); - - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.notHandled()).toBe(true); - }); - - it('does not handle requests with non-empty `loginAttempt`.', async () => { - const request = requestFixture(); - - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); + describe('`login` method', () => { + it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => { + const request = requestFixture({ path: '/api/security/v1/oidc' }); - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - sinon.assert.notCalled(callWithRequest); - sinon.assert.notCalled(callWithInternalUser); - expect(authenticationResult.notHandled()).toBe(true); - }); - - it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { - const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); - - callWithInternalUser.withArgs('shield.oidcPrepare').resolves({ - state: 'statevalue', - nonce: 'noncevalue', - redirect: - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', - }); - - const authenticationResult = await provider.authenticate(request, null); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' - ); - expect(authenticationResult.state).toEqual({ - state: 'statevalue', - nonce: 'noncevalue', - nextURL: `/s/foo/some-path`, - }); - }); - - it('redirects third party initiated authentications to the OpenId Connect Provider.', async () => { - const request = requestFixture({ - path: '/api/security/v1/oidc', - search: '?iss=theissuer&login_hint=loginhint', - basePath: '/s/foo', - }); - - callWithInternalUser.withArgs('shield.oidcPrepare').resolves({ + mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ state: 'statevalue', nonce: 'noncevalue', redirect: @@ -122,10 +55,13 @@ describe('OIDCAuthenticationProvider', () => { '&login_hint=loginhint', }); - const authenticationResult = await provider.authenticate(request, null); + const authenticationResult = await provider.login(request, { + iss: 'theissuer', + loginHint: 'loginhint', + }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { - body: { iss: `theissuer`, login_hint: `loginhint` }, + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + body: { iss: 'theissuer', login_hint: 'loginhint' }, }); expect(authenticationResult.redirected()).toBe(true); @@ -140,52 +76,39 @@ describe('OIDCAuthenticationProvider', () => { expect(authenticationResult.state).toEqual({ state: 'statevalue', nonce: 'noncevalue', - nextURL: `/s/foo/`, + nextURL: '/base-path/', }); }); - it('fails if OpenID Connect authentication request preparation fails.', async () => { - const request = requestFixture({ path: '/some-path' }); - - const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.oidcPrepare').returns(Promise.reject(failureReason)); - - const authenticationResult = await provider.authenticate(request, null); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { const request = requestFixture({ path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - search: '?code=somecodehere&state=somestatehere', }); - callWithInternalUser + mockOptions.client.callAsInternalUser .withArgs('shield.oidcAuthenticate') .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); - const authenticationResult = await provider.authenticate(request, { - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/test-base-path/some-path', - }); + const authenticationResult = await provider.login( + request, + { code: 'somecodehere' }, + { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path' } + ); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - }, - }); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.oidcAuthenticate', + { + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + }, + } + ); expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path'); + expect(authenticationResult.redirectURL).toBe('/base-path/some-path'); expect(authenticationResult.state).toEqual({ accessToken: 'some-token', refreshToken: 'some-refresh-token', @@ -193,16 +116,15 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if authentication response is presented but session state does not contain the state parameter.', async () => { - const request = requestFixture({ - path: '/api/security/v1/oidc', - search: '?code=somecodehere&state=somestatehere', - }); + const request = requestFixture({ path: '/api/security/v1/oidc' }); - const authenticationResult = await provider.authenticate(request, { - nextURL: '/test-base-path/some-path', - }); + const authenticationResult = await provider.login( + request, + { code: 'somecodehere' }, + { nextURL: '/base-path/some-path' } + ); - sinon.assert.notCalled(callWithInternalUser); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( @@ -213,17 +135,15 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if authentication response is presented but session state does not contain redirect URL.', async () => { - const request = requestFixture({ - path: '/api/security/v1/oidc', - search: '?code=somecodehere&state=somestatehere', - }); + const request = requestFixture({ path: '/api/security/v1/oidc' }); - const authenticationResult = await provider.authenticate(request, { - state: 'statevalue', - nonce: 'noncevalue', - }); + const authenticationResult = await provider.login( + request, + { code: 'somecodehere' }, + { state: 'statevalue', nonce: 'noncevalue' } + ); - sinon.assert.notCalled(callWithInternalUser); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( @@ -236,12 +156,11 @@ describe('OIDCAuthenticationProvider', () => { it('fails if session state is not presented.', async () => { const request = requestFixture({ path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - search: '?code=somecodehere&state=somestatehere', }); - const authenticationResult = await provider.authenticate(request, {}); + const authenticationResult = await provider.login(request, { code: 'somecodehere' }, {}); - sinon.assert.notCalled(callWithInternalUser); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); expect(authenticationResult.failed()).toBe(true); }); @@ -249,28 +168,94 @@ describe('OIDCAuthenticationProvider', () => { it('fails if code is invalid.', async () => { const request = requestFixture({ path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - search: '?code=somecodehere&state=somestatehere', }); const failureReason = new Error( 'Failed to exchange code for Id Token using the Token Endpoint.' ); - callWithInternalUser + mockOptions.client.callAsInternalUser .withArgs('shield.oidcAuthenticate') .returns(Promise.reject(failureReason)); - const authenticationResult = await provider.authenticate(request, { + const authenticationResult = await provider.login( + request, + { code: 'somecodehere' }, + { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path' } + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.oidcAuthenticate', + { + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + }, + } + ); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + }); + + describe('`authenticate` method', () => { + it('does not handle AJAX request that can not be authenticated.', async () => { + const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { + const request = requestFixture({ path: '/s/foo/some-path' }); + + mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ state: 'statevalue', nonce: 'noncevalue', - nextURL: '/test-base-path/some-path', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', + }); + + const authenticationResult = await provider.authenticate(request, null); + + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + body: { realm: `oidc1` }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + ); + expect(authenticationResult.state).toEqual({ + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/base-path/s/foo/some-path', }); + }); + + it('fails if OpenID Connect authentication request preparation fails.', async () => { + const request = requestFixture({ path: '/some-path' }); + + const failureReason = new Error('Realm is misconfigured!'); + mockOptions.client.callAsInternalUser + .withArgs('shield.oidcPrepare') + .returns(Promise.reject(failureReason)); + + const authenticationResult = await provider.authenticate(request, null); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - }, + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + body: { realm: `oidc1` }, }); expect(authenticationResult.failed()).toBe(true); @@ -278,20 +263,25 @@ describe('OIDCAuthenticationProvider', () => { }); it('succeeds if state contains a valid token.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request, { + const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', - }); + }; + const authorization = `Bearer ${tokenPair.accessToken}`; - expect(request.headers.authorization).toBe('Bearer some-valid-token'); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toEqual({ authorization }); expect(authenticationResult.user).toBe(user); - expect(authenticationResult.state).toBe(undefined); + expect(authenticationResult.state).toBeUndefined(); }); it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { @@ -302,57 +292,61 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }); - sinon.assert.notCalled(callWithRequest); + sinon.assert.notCalled(mockOptions.client.asScoped); expect(request.headers.authorization).toBe('Basic some:credentials'); expect(authenticationResult.notHandled()).toBe(true); }); it('fails if token from the state is rejected because of unknown reason.', async () => { const request = requestFixture(); + const tokenPair = { + accessToken: 'some-invalid-token', + refreshToken: 'some-invalid-refresh-token', + }; + const authorization = `Bearer ${tokenPair.accessToken}`; const failureReason = new Error('Token is not valid!'); - callWithRequest - .withArgs(request, 'shield.authenticate') - .returns(Promise.reject(failureReason)); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-invalid-token', - refreshToken: 'some-invalid-refresh-token', - }); + const authenticationResult = await provider.authenticate(request, tokenPair); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); - sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken'); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer new-access-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer new-access-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); - tokens.refresh + mockOptions.tokens.refresh .withArgs(tokenPair.refreshToken) .resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); const authenticationResult = await provider.authenticate(request, tokenPair); - expect(request.headers.authorization).toBe('Bearer new-access-token'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toEqual({ + authorization: 'Bearer new-access-token', + }); expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toEqual({ accessToken: 'new-access-token', @@ -364,18 +358,18 @@ describe('OIDCAuthenticationProvider', () => { const request = requestFixture(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); const refreshFailureReason = { statusCode: 500, message: 'Something is wrong with refresh token.', }; - tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -385,10 +379,10 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => { - const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); + const request = requestFixture({ path: '/s/foo/some-path' }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; - callWithInternalUser.withArgs('shield.oidcPrepare').resolves({ + mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ state: 'statevalue', nonce: 'noncevalue', redirect: @@ -399,18 +393,18 @@ describe('OIDCAuthenticationProvider', () => { '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { body: { realm: `oidc1` }, }); @@ -425,7 +419,7 @@ describe('OIDCAuthenticationProvider', () => { expect(authenticationResult.state).toEqual({ state: 'statevalue', nonce: 'noncevalue', - nextURL: `/s/foo/some-path`, + nextURL: '/base-path/s/foo/some-path', }); }); @@ -433,14 +427,14 @@ describe('OIDCAuthenticationProvider', () => { const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -452,26 +446,31 @@ describe('OIDCAuthenticationProvider', () => { }); it('succeeds if `authorization` contains a valid token.', async () => { - const user = { username: 'user' }; - const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } }); + const user = mockAuthenticatedUser(); + const authorization = 'Bearer some-valid-token'; + const request = requestFixture({ headers: { authorization } }); - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request); expect(request.headers.authorization).toBe('Bearer some-valid-token'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.user).toBe(user); - expect(authenticationResult.state).toBe(undefined); + expect(authenticationResult.state).toBeUndefined(); }); it('fails if token from `authorization` header is rejected.', async () => { - const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); + const authorization = 'Bearer some-invalid-token'; + const request = requestFixture({ headers: { authorization } }); - const failureReason = new Error('Token is not valid!'); - callWithRequest - .withArgs(request, 'shield.authenticate') - .returns(Promise.reject(failureReason)); + const failureReason = { statusCode: 401 }; + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); const authenticationResult = await provider.authenticate(request); @@ -480,16 +479,20 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { - const user = { username: 'user' }; - const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); - - const failureReason = new Error('Token is not valid!'); - callWithRequest - .withArgs(request, 'shield.authenticate') - .returns(Promise.reject(failureReason)); - - callWithRequest - .withArgs(sinon.match({ headers: { authorization: 'Bearer some-valid-token' } })) + const user = mockAuthenticatedUser(); + const authorization = 'Bearer some-invalid-token'; + const request = requestFixture({ headers: { authorization } }); + + const failureReason = { statusCode: 401 }; + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); const authenticationResult = await provider.authenticate(request, { @@ -502,20 +505,20 @@ describe('OIDCAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { const request = requestFixture(); - let deauthenticateResult = await provider.deauthenticate(request, {}); + let deauthenticateResult = await provider.logout(request, {}); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, {}); + deauthenticateResult = await provider.logout(request, {}); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, { nonce: 'x' }); + deauthenticateResult = await provider.logout(request, { nonce: 'x' }); expect(deauthenticateResult.notHandled()).toBe(true); - sinon.assert.notCalled(callWithInternalUser); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); }); it('fails if OpenID Connect logout call fails.', async () => { @@ -524,15 +527,17 @@ describe('OIDCAuthenticationProvider', () => { const refreshToken = 'x-oidc-refresh-token'; const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.oidcLogout').returns(Promise.reject(failureReason)); + mockOptions.client.callAsInternalUser + .withArgs('shield.oidcLogout') + .returns(Promise.reject(failureReason)); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', { + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -545,20 +550,22 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - callWithInternalUser.withArgs('shield.oidcLogout').resolves({ redirect: null }); + mockOptions.client.callAsInternalUser + .withArgs('shield.oidcLogout') + .resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', { + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/logged_out'); + expect(authenticationResult.redirectURL).toBe('/base-path/logged_out'); }); it('redirects user to the OpenID Connect Provider if RP initiated SLO is supported.', async () => { @@ -566,16 +573,16 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - callWithInternalUser + mockOptions.client.callAsInternalUser .withArgs('shield.oidcLogout') .resolves({ redirect: 'http://fake-idp/logout&id_token_hint=thehint' }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('http://fake-idp/logout&id_token_hint=thehint'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 074bc5cc7ee73f..f7695a060bbdf9 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -6,8 +6,8 @@ import Boom from 'boom'; import type from 'type-detect'; -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../can_redirect_request'; +import { canRedirectRequest } from '../'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { Tokens, TokenPair } from '../tokens'; @@ -15,9 +15,17 @@ import { AuthenticationProviderOptions, BaseAuthenticationProvider, AuthenticationProviderSpecificOptions, - RequestWithLoginAttempt, } from './base'; +/** + * Describes the parameters that are required by the provider to process the initial login request. + */ +interface ProviderLoginAttempt { + code?: string; + iss?: string; + loginHint?: string; +} + /** * The state supported by the provider (for the OpenID Connect handshake or established session). */ @@ -40,49 +48,13 @@ interface ProviderState extends Partial { nextURL?: string; } -/** - * Defines the shape of an incoming OpenID Connect Request - */ -type OIDCIncomingRequest = RequestWithLoginAttempt & { - payload: { - iss?: string; - login_hint?: string; - }; - query: { - iss?: string; - code?: string; - state?: string; - login_hint?: string; - error?: string; - error_description?: string; - }; -}; - -/** - * Checks if the Request object represents an HTTP request regarding authentication with OpenID - * Connect. This can be - * - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication - * - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication - * - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from - * an OpenID Connect Provider - * - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from - * an OpenID Connect Provider - * @param request Request instance. - */ -function isOIDCIncomingRequest(request: RequestWithLoginAttempt): request is OIDCIncomingRequest { - return ( - (request.payload != null && !!(request.payload as Record).iss) || - (request.query != null && - (!!(request.query as any).iss || - !!(request.query as any).code || - !!(request.query as any).error)) - ); -} - /** * Provider that supports authentication using an OpenID Connect realm in Elasticsearch. */ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Specifies Elasticsearch OIDC realm name that Kibana should use. + */ private readonly realm: string; constructor( @@ -101,13 +73,31 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.realm = oidcOptions.realm as string; } + /** + * Performs OpenID Connect request authentication. + * @param request Request instance. + * @param attempt Login attempt description. + * @param [state] Optional state object associated with the provider. + */ + public async login( + request: KibanaRequest, + attempt: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.logger.debug('Trying to perform a login.'); + + // This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or + // a third party initiating an authentication + return await this.loginWithOIDCPayload(request, attempt, state); + } + /** * Performs OpenID Connect request authentication. * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. let { @@ -118,11 +108,6 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return authenticationResult; } - if (request.loginAttempt().getCredentials() != null) { - this.debug('Login attempt is detected, but it is not supported by the provider'); - return AuthenticationResult.notHandled(); - } - if (state && authenticationResult.notHandled()) { authenticationResult = await this.authenticateViaState(request, state); if ( @@ -133,12 +118,6 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } } - if (isOIDCIncomingRequest(request) && authenticationResult.notHandled()) { - // This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or - // a third party initiating an authentication - authenticationResult = await this.authenticateViaResponseUrl(request, state); - } - // If we couldn't authenticate by means of all methods above, let's try to // initiate an OpenID Connect based authentication, otherwise just return the authentication result we have. // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in @@ -161,30 +140,31 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * to the URL that was requested before authentication flow started or to default Kibana location in case of a third * party initiated login * @param request Request instance. + * @param attempt Login attempt description. * @param [sessionState] Optional state object associated with the provider. */ - private async authenticateViaResponseUrl( - request: OIDCIncomingRequest, + private async loginWithOIDCPayload( + request: KibanaRequest, + { iss, loginHint, code }: ProviderLoginAttempt, sessionState?: ProviderState | null ) { - this.debug('Trying to authenticate via OpenID Connect response query.'); - // First check to see if this is a Third Party initiated authentication (which can happen via POST or GET) - const iss = (request.query && request.query.iss) || (request.payload && request.payload.iss); - const loginHint = - (request.query && request.query.login_hint) || - (request.payload && request.payload.login_hint); + this.logger.debug('Trying to authenticate via OpenID Connect response query.'); + + // First check to see if this is a Third Party initiated authentication. if (iss) { - this.debug('Authentication has been initiated by a Third Party.'); + this.logger.debug('Authentication has been initiated by a Third Party.'); + // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) const oidcPrepareParams = loginHint ? { iss, login_hint: loginHint } : { iss }; return this.initiateOIDCAuthentication(request, oidcPrepareParams); } - if (!request.query || !request.query.code) { - this.debug('OpenID Connect Authentication response is not found.'); + if (!code) { + this.logger.debug('OpenID Connect Authentication response is not found.'); return AuthenticationResult.notHandled(); } + // If it is an authentication response and the users' session state doesn't contain all the necessary information, // then something unexpected happened and we should fail because Elasticsearch won't be able to validate the // response. @@ -193,7 +173,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (!stateNonce || !stateOIDCState || !stateRedirectURL) { const message = 'Response session state does not have corresponding state or nonce parameters or redirect URL.'; - this.debug(message); + this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } @@ -204,7 +184,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const { access_token: accessToken, refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.oidcAuthenticate', { + } = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', { body: { state: stateOIDCState, nonce: stateNonce, @@ -215,14 +195,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }, }); - this.debug('Request has been authenticated via OpenID Connect.'); + this.logger.debug('Request has been authenticated via OpenID Connect.'); return AuthenticationResult.redirectTo(stateRedirectURL, { accessToken, refreshToken, }); } catch (err) { - this.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -235,15 +215,15 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param [sessionState] Optional state object associated with the provider. */ private async initiateOIDCAuthentication( - request: RequestWithLoginAttempt, + request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, sessionState?: ProviderState | null ) { - this.debug('Trying to initiate OpenID Connect authentication.'); + this.logger.debug('Trying to initiate OpenID Connect authentication.'); // If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication. if (!canRedirectRequest(request)) { - this.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); + this.logger.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); return AuthenticationResult.notHandled(); } @@ -259,16 +239,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { : params; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`. - const { state, nonce, redirect } = await this.options.client.callWithInternalUser( + const { state, nonce, redirect } = await this.options.client.callAsInternalUser( 'shield.oidcPrepare', - { - body: oidcPrepareParams, - } + { body: oidcPrepareParams } ); - this.debug('Redirecting to OpenID Connect Provider with authentication request.'); + this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.'); // If this is a third party initiated login, redirect to the base path - const redirectAfterLogin = `${request.getBasePath()}${ + const redirectAfterLogin = `${this.options.basePath.get(request)}${ 'iss' in params ? '/' : request.url.path }`; return AuthenticationResult.redirectTo( @@ -277,7 +255,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { { state, nonce, nextURL: redirectAfterLogin } ); } catch (err) { - this.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); + this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -287,12 +265,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled(), }; @@ -300,7 +278,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -308,15 +286,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via header.'); + const user = await this.getUser(request); + this.logger.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user), }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err), }; @@ -329,35 +306,22 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); if (!accessToken) { - this.debug('Elasticsearch access token is not found in state.'); + this.logger.debug('Elasticsearch access token is not found in state.'); return AuthenticationResult.notHandled(); } - request.headers.authorization = `Bearer ${accessToken}`; - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.debug('Request has been authenticated via state.'); - - return AuthenticationResult.succeeded(user); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.debug(`Failed to authenticate request via state: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -370,13 +334,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, + request: KibanaRequest, { refreshToken }: ProviderState ) { - this.debug('Trying to refresh elasticsearch access token.'); + this.logger.debug('Trying to refresh elasticsearch access token.'); if (!refreshToken) { - this.debug('Refresh token is not found in state.'); + this.logger.debug('Refresh token is not found in state.'); return AuthenticationResult.notHandled(); } @@ -395,7 +359,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // supported. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.debug( + this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); return this.initiateOIDCAuthentication(request, { realm: this.realm }); @@ -407,22 +371,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug(`Failed to refresh elasticsearch access token: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -433,11 +388,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state: ProviderState) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state: ProviderState) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if (!state || !state.accessToken) { - this.debug('There is no elasticsearch access token to invalidate.'); + this.logger.debug('There is no elasticsearch access token to invalidate.'); return DeauthenticationResult.notHandled(); } @@ -450,33 +405,25 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/logout`. - const { redirect } = await this.options.client.callWithInternalUser( + const { redirect } = await this.options.client.callAsInternalUser( 'shield.oidcLogout', logoutBody ); - this.debug('User session has been successfully invalidated.'); + this.logger.debug('User session has been successfully invalidated.'); // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect // Provider to properly complete logout. if (redirect != null) { - this.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); + this.logger.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); return DeauthenticationResult.redirectTo(redirect); } - return DeauthenticationResult.redirectTo(`${this.options.basePath}/logged_out`); + return DeauthenticationResult.redirectTo(`${this.options.basePath.get(request)}/logged_out`); } catch (err) { - this.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); } } - - /** - * Logs message with `debug` level and oidc/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'oidc'], message); - } } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 7029ddcc178177..8c7ad4700fa573 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -6,29 +6,27 @@ import Boom from 'boom'; import sinon from 'sinon'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; -import { mockAuthenticationProviderOptions } from './base.mock'; +import { requestFixture } from '../../__fixtures__'; +import { + MockAuthenticationProviderOptions, + mockAuthenticationProviderOptions, + mockScopedClusterClient, +} from './base.mock'; import { SAMLAuthenticationProvider } from './saml'; describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - let callWithInternalUser: sinon.SinonStub; - let tokens: ReturnType['tokens']; + let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' }); - callWithRequest = providerOptions.client.callWithRequest; - callWithInternalUser = providerOptions.client.callWithInternalUser; - tokens = providerOptions.tokens; - - provider = new SAMLAuthenticationProvider(providerOptions, { realm: 'test-realm' }); + mockOptions = mockAuthenticationProviderOptions(); + provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm' }); }); it('throws if `realm` option is not specified', () => { - const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' }); + const providerOptions = mockAuthenticationProviderOptions(); expect(() => new SAMLAuthenticationProvider(providerOptions)).toThrowError( 'Realm name must be specified' @@ -41,177 +39,419 @@ describe('SAMLAuthenticationProvider', () => { ); }); - describe('`authenticate` method', () => { - it('does not handle AJAX request that can not be authenticated.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + describe('`login` method', () => { + it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { + const request = requestFixture(); - const authenticationResult = await provider.authenticate(request, null); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); - expect(authenticationResult.notHandled()).toBe(true); - }); + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', nextURL: '/test-base-path/some-path' } + ); - it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } }); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: ['some-request-id'], content: 'saml-response-xml' } } + ); - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path'); + expect(authenticationResult.state).toEqual({ + accessToken: 'some-token', + refreshToken: 'some-refresh-token', }); + }); - sinon.assert.notCalled(callWithRequest); - expect(request.headers.authorization).toBe('Basic some:credentials'); - expect(authenticationResult.notHandled()).toBe(true); + it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => { + const request = requestFixture(); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { nextURL: '/test-base-path/some-path' } + ); + + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toEqual( + Boom.badRequest( + 'SAML response state does not have corresponding request id or redirect URL.' + ) + ); }); - it('does not handle requests with non-empty `loginAttempt`.', async () => { + it('fails if SAML Response payload is presented but state does not contain redirect URL.', async () => { const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id' } + ); - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - sinon.assert.notCalled(callWithRequest); - expect(authenticationResult.notHandled()).toBe(true); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toEqual( + Boom.badRequest( + 'SAML response state does not have corresponding request id or redirect URL.' + ) + ); }); - it('redirects non-AJAX request that can not be authenticated to the IdP.', async () => { - const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); + it('redirects to the default location if state is not presented.', async () => { + const request = requestFixture(); - callWithInternalUser.withArgs('shield.samlPrepare').resolves({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + access_token: 'idp-initiated-login-token', + refresh_token: 'idp-initiated-login-refresh-token', }); - const authenticationResult = await provider.authenticate(request, null); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { - body: { realm: 'test-realm' }, + const authenticationResult = await provider.login(request, { + samlResponse: 'saml-response-xml', }); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: [], content: 'saml-response-xml' } } ); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/base-path/'); expect(authenticationResult.state).toEqual({ - requestId: 'some-request-id', - nextURL: `/s/foo/some-path`, + accessToken: 'idp-initiated-login-token', + refreshToken: 'idp-initiated-login-refresh-token', }); }); - it('fails if SAML request preparation fails.', async () => { - const request = requestFixture({ path: '/some-path' }); + it('fails if SAML Response is rejected.', async () => { + const request = requestFixture(); - const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); + const failureReason = new Error('SAML response is stale!'); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .rejects(failureReason); - const authenticationResult = await provider.authenticate(request, null); + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', nextURL: '/test-base-path/some-path' } + ); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: ['some-request-id'], content: 'saml-response-xml' } } + ); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); }); - it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); + describe('IdP initiated login with existing session', () => { + it('fails if new SAML Response is rejected.', async () => { + const request = requestFixture(); - callWithInternalUser - .withArgs('shield.samlAuthenticate') - .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + const user = mockAuthenticatedUser(); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); - const authenticationResult = await provider.authenticate(request, { - requestId: 'some-request-id', - nextURL: '/test-base-path/some-path', + const failureReason = new Error('SAML response is invalid!'); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .rejects(failureReason); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token' } + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: [], content: 'saml-response-xml' } } + ); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: ['some-request-id'], content: 'saml-response-xml' }, + it('fails if token received in exchange to new SAML Response is rejected.', async () => { + const request = requestFixture(); + + // Call to `authenticate` using existing valid session. + const user = mockAuthenticatedUser(); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer existing-valid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + // Call to `authenticate` with token received in exchange to new SAML payload. + const failureReason = new Error('Access token is invalid!'); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer new-invalid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .resolves({ access_token: 'new-invalid-token', refresh_token: 'new-invalid-token' }); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token' } + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: [], content: 'saml-response-xml' } } + ); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); }); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path'); - expect(authenticationResult.state).toEqual({ - accessToken: 'some-token', - refreshToken: 'some-refresh-token', + it('fails if fails to invalidate existing access/refresh tokens.', async () => { + const request = requestFixture(); + const tokenPair = { + accessToken: 'existing-valid-token', + refreshToken: 'existing-valid-refresh-token', + }; + + const user = mockAuthenticatedUser(); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); + + const failureReason = new Error('Failed to invalidate token!'); + mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + tokenPair + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: [], content: 'saml-response-xml' } } + ); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); }); - }); - it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); + it('redirects to the home page if new SAML Response is for the same user.', async () => { + const request = requestFixture(); + const tokenPair = { + accessToken: 'existing-valid-token', + refreshToken: 'existing-valid-refresh-token', + }; - const authenticationResult = await provider.authenticate(request, { - nextURL: '/test-base-path/some-path', - } as any); + const user = { username: 'user', authentication_realm: { name: 'saml1' } }; + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); - sinon.assert.notCalled(callWithInternalUser); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest( - 'SAML response state does not have corresponding request id or redirect URL.' + mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + tokenPair + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: [], content: 'saml-response-xml' } } + ); + + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/base-path/'); + }); + + it('redirects to `overwritten_session` if new SAML Response is for the another user.', async () => { + const request = requestFixture(); + const tokenPair = { + accessToken: 'existing-valid-token', + refreshToken: 'existing-valid-refresh-token', + }; + + const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) ) - ); - }); + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(existingUser); - it('fails if SAML Response payload is presented but state does not contain redirect URL.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); + const newUser = { username: 'new-user', authentication_realm: { name: 'saml1' } }; + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer new-valid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(newUser); - const authenticationResult = await provider.authenticate(request, { - requestId: 'some-request-id', - } as any); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); - sinon.assert.notCalled(callWithInternalUser); + mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest( - 'SAML response state does not have corresponding request id or redirect URL.' + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + tokenPair + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: [], content: 'saml-response-xml' } } + ); + + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/base-path/overwritten_session'); + }); + + it('redirects to `overwritten_session` if new SAML Response is for another realm.', async () => { + const request = requestFixture(); + const tokenPair = { + accessToken: 'existing-valid-token', + refreshToken: 'existing-valid-refresh-token', + }; + + const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) ) - ); + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(existingUser); + + const newUser = { username: 'user', authentication_realm: { name: 'saml2' } }; + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer new-valid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(newUser); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); + + mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + tokenPair + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: [], content: 'saml-response-xml' } } + ); + + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/base-path/overwritten_session'); + }); + }); + }); + + describe('`authenticate` method', () => { + it('does not handle AJAX request that can not be authenticated.', async () => { + const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.notHandled()).toBe(true); }); - it('redirects to the default location if state is not presented.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); + it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } }); - callWithInternalUser.withArgs('shield.samlAuthenticate').resolves({ - access_token: 'idp-initiated-login-token', - refresh_token: 'idp-initiated-login-refresh-token', + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', }); - const authenticationResult = await provider.authenticate(request); + sinon.assert.notCalled(mockOptions.client.asScoped); + expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(authenticationResult.notHandled()).toBe(true); + }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, + it('redirects non-AJAX request that can not be authenticated to the IdP.', async () => { + const request = requestFixture({ path: '/s/foo/some-path' }); + + mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }); + + const authenticationResult = await provider.authenticate(request, null); + + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + body: { realm: 'test-realm' }, }); expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/'); + expect(authenticationResult.redirectURL).toBe( + 'https://idp-host/path/login?SAMLRequest=some%20request%20' + ); expect(authenticationResult.state).toEqual({ - accessToken: 'idp-initiated-login-token', - refreshToken: 'idp-initiated-login-refresh-token', + requestId: 'some-request-id', + nextURL: `/base-path/s/foo/some-path`, }); }); - it('fails if SAML Response is rejected.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); + it('fails if SAML request preparation fails.', async () => { + const request = requestFixture({ path: '/some-path' }); - const failureReason = new Error('SAML response is stale!'); - callWithInternalUser.withArgs('shield.samlAuthenticate').rejects(failureReason); + const failureReason = new Error('Realm is misconfigured!'); + mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); - const authenticationResult = await provider.authenticate(request, { - requestId: 'some-request-id', - nextURL: '/test-base-path/some-path', - }); + const authenticationResult = await provider.authenticate(request, null); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: ['some-request-id'], content: 'saml-response-xml' }, + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + body: { realm: 'test-realm' }, }); expect(authenticationResult.failed()).toBe(true); @@ -219,66 +459,79 @@ describe('SAMLAuthenticationProvider', () => { }); it('succeeds if state contains a valid token.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request, { + const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', - }); + }; + const authorization = `Bearer ${tokenPair.accessToken}`; - expect(request.headers.authorization).toBe('Bearer some-valid-token'); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toEqual({ authorization }); expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBeUndefined(); }); it('fails if token from the state is rejected because of unknown reason.', async () => { const request = requestFixture(); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; const failureReason = { statusCode: 500, message: 'Token is not valid!' }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-invalid-token', - refreshToken: 'some-invalid-refresh-token', - }); + const authenticationResult = await provider.authenticate(request, tokenPair); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); - sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken'); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer new-access-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer new-access-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); - tokens.refresh + mockOptions.tokens.refresh .withArgs(tokenPair.refreshToken) .resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); const authenticationResult = await provider.authenticate(request, tokenPair); - expect(request.headers.authorization).toBe('Bearer new-access-token'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toEqual({ + authorization: 'Bearer new-access-token', + }); expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toEqual({ accessToken: 'new-access-token', @@ -290,18 +543,18 @@ describe('SAMLAuthenticationProvider', () => { const request = requestFixture(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); const refreshFailureReason = { statusCode: 500, message: 'Something is wrong with refresh token.', }; - tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -314,14 +567,14 @@ describe('SAMLAuthenticationProvider', () => { const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -333,29 +586,29 @@ describe('SAMLAuthenticationProvider', () => { }); it('initiates SAML handshake for non-AJAX requests if access token document is missing.', async () => { - const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); + const request = requestFixture({ path: '/s/foo/some-path' }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; - callWithInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 500, body: { error: { reason: 'token document is missing and must be present' } }, }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { body: { realm: 'test-realm' }, }); @@ -365,31 +618,31 @@ describe('SAMLAuthenticationProvider', () => { ); expect(authenticationResult.state).toEqual({ requestId: 'some-request-id', - nextURL: `/s/foo/some-path`, + nextURL: `/base-path/s/foo/some-path`, }); }); it('initiates SAML handshake for non-AJAX requests if refresh token is expired.', async () => { - const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); + const request = requestFixture({ path: '/s/foo/some-path' }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; - callWithInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { body: { realm: 'test-realm' }, }); @@ -399,29 +652,36 @@ describe('SAMLAuthenticationProvider', () => { ); expect(authenticationResult.state).toEqual({ requestId: 'some-request-id', - nextURL: `/s/foo/some-path`, + nextURL: `/base-path/s/foo/some-path`, }); }); it('succeeds if `authorization` contains a valid token.', async () => { - const user = { username: 'user' }; - const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } }); + const user = mockAuthenticatedUser(); + const authorization = 'Bearer some-valid-token'; + const request = requestFixture({ headers: { authorization } }); - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request); expect(request.headers.authorization).toBe('Bearer some-valid-token'); expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBeUndefined(); }); it('fails if token from `authorization` header is rejected.', async () => { - const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); + const authorization = 'Bearer some-invalid-token'; + const request = requestFixture({ headers: { authorization } }); const failureReason = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); const authenticationResult = await provider.authenticate(request); @@ -430,14 +690,20 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { - const user = { username: 'user' }; - const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); + const user = mockAuthenticatedUser(); + const authorization = 'Bearer some-invalid-token'; + const request = requestFixture({ headers: { authorization } }); const failureReason = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); - - callWithRequest - .withArgs(sinon.match({ headers: { authorization: 'Bearer some-valid-token' } })) + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); const authenticationResult = await provider.authenticate(request, { @@ -448,227 +714,22 @@ describe('SAMLAuthenticationProvider', () => { expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); }); - - describe('IdP initiated login with existing session', () => { - it('fails if new SAML Response is rejected.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - - const user = { username: 'user' }; - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const failureReason = new Error('SAML response is invalid!'); - callWithInternalUser.withArgs('shield.samlAuthenticate').rejects(failureReason); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('fails if token received in exchange to new SAML Response is rejected.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - - // Call to `authenticate` using existing valid session. - const user = { username: 'user' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer existing-valid-token' } }), - 'shield.authenticate' - ) - .resolves(user); - - // Call to `authenticate` with token received in exchange to new SAML payload. - const failureReason = new Error('Access token is invalid!'); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer new-invalid-token' } }), - 'shield.authenticate' - ) - .rejects(failureReason); - - callWithInternalUser - .withArgs('shield.samlAuthenticate') - .resolves({ access_token: 'new-invalid-token', refresh_token: 'new-invalid-token' }); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - }); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('fails if fails to invalidate existing access/refresh tokens.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - const tokenPair = { - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - }; - - const user = { username: 'user' }; - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - callWithInternalUser - .withArgs('shield.samlAuthenticate') - .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); - - const failureReason = new Error('Failed to invalidate token!'); - tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('redirects to the home page if new SAML Response is for the same user.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - const tokenPair = { - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - }; - - const user = { username: 'user', authentication_realm: { name: 'saml1' } }; - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - callWithInternalUser - .withArgs('shield.samlAuthenticate') - .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); - - tokens.invalidate.withArgs(tokenPair).resolves(); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - }); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, - }); - - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/'); - }); - - it('redirects to `overwritten_session` if new SAML Response is for the another user.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - const tokenPair = { - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - }; - - const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) - .resolves(existingUser); - - const newUser = { username: 'new-user', authentication_realm: { name: 'saml1' } }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer new-valid-token' } }), - 'shield.authenticate' - ) - .resolves(newUser); - - callWithInternalUser - .withArgs('shield.samlAuthenticate') - .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); - - tokens.invalidate.withArgs(tokenPair).resolves(); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, - }); - - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/overwritten_session'); - }); - - it('redirects to `overwritten_session` if new SAML Response is for another realm.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - const tokenPair = { - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - }; - - const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer existing-valid-token' } }), - 'shield.authenticate' - ) - .resolves(existingUser); - - const newUser = { username: 'user', authentication_realm: { name: 'saml2' } }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer new-valid-token' } }), - 'shield.authenticate' - ) - .resolves(newUser); - - callWithInternalUser - .withArgs('shield.samlAuthenticate') - .resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' }); - - tokens.invalidate.withArgs(tokenPair).resolves(); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, - }); - - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/overwritten_session'); - }); - }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { const request = requestFixture(); - let deauthenticateResult = await provider.deauthenticate(request); + let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, {} as any); + deauthenticateResult = await provider.logout(request, {} as any); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, { somethingElse: 'x' } as any); + deauthenticateResult = await provider.logout(request, { somethingElse: 'x' } as any); expect(deauthenticateResult.notHandled()).toBe(true); - sinon.assert.notCalled(callWithInternalUser); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); }); it('fails if SAML logout call fails.', async () => { @@ -677,15 +738,15 @@ describe('SAMLAuthenticationProvider', () => { const refreshToken = 'x-saml-refresh-token'; const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.samlLogout').rejects(failureReason); + mockOptions.client.callAsInternalUser.withArgs('shield.samlLogout').rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -697,17 +758,18 @@ describe('SAMLAuthenticationProvider', () => { const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.samlInvalidate').rejects(failureReason); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request); + const authenticationResult = await provider.logout(request); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - realm: 'test-realm', - }, - }); + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlInvalidate', + { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } + ); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -718,15 +780,17 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null }); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlLogout') + .resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -739,15 +803,17 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: undefined }); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlLogout') + .resolves({ redirect: undefined }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -760,15 +826,17 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null }); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlLogout') + .resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -779,20 +847,21 @@ describe('SAMLAuthenticationProvider', () => { it('relies on SAML invalidate call even if access token is presented.', async () => { const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); - callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: null }); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', }); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - realm: 'test-realm', - }, - }); + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlInvalidate', + { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } + ); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/logged_out'); @@ -801,17 +870,18 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => { const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); - callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: null }); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .resolves({ redirect: null }); - const authenticationResult = await provider.deauthenticate(request); + const authenticationResult = await provider.logout(request); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - realm: 'test-realm', - }, - }); + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlInvalidate', + { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } + ); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/logged_out'); @@ -820,17 +890,18 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); - callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: undefined }); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .resolves({ redirect: undefined }); - const authenticationResult = await provider.deauthenticate(request); + const authenticationResult = await provider.logout(request); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - realm: 'test-realm', - }, - }); + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlInvalidate', + { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } + ); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/logged_out'); @@ -841,16 +912,16 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - callWithInternalUser + mockOptions.client.callAsInternalUser .withArgs('shield.samlLogout') .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(callWithInternalUser); + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); }); @@ -858,16 +929,16 @@ describe('SAMLAuthenticationProvider', () => { it('redirects user to the IdP if SLO is supported by IdP in case of IdP initiated logout.', async () => { const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); - callWithInternalUser + mockOptions.client.callAsInternalUser .withArgs('shield.samlInvalidate') .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); - const authenticationResult = await provider.deauthenticate(request, { + const authenticationResult = await provider.logout(request, { accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', }); - sinon.assert.calledOnce(callWithInternalUser); + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 7017a15b5e11be..0ed4fe0a2eafca 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -5,17 +5,13 @@ */ import Boom from 'boom'; -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../can_redirect_request'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; import { Tokens, TokenPair } from '../tokens'; -import { - AuthenticationProviderOptions, - BaseAuthenticationProvider, - RequestWithLoginAttempt, -} from './base'; +import { canRedirectRequest } from '..'; /** * The state supported by the provider (for the SAML handshake or established session). @@ -33,34 +29,17 @@ interface ProviderState extends Partial { } /** - * Defines the shape of the request query containing SAML request. + * Describes the parameters that are required by the provider to process the initial login request. */ -interface SAMLRequestQuery { - SAMLRequest: string; -} - -/** - * Defines the shape of the request with a body containing SAML response. - */ -type RequestWithSAMLPayload = RequestWithLoginAttempt & { - payload: { SAMLResponse: string; RelayState?: string }; -}; - -/** - * Checks whether request payload contains SAML response from IdP. - * @param request Request instance. - */ -function isRequestWithSAMLResponsePayload( - request: RequestWithLoginAttempt -): request is RequestWithSAMLPayload { - return request.payload != null && !!(request.payload as any).SAMLResponse; +interface ProviderLoginAttempt { + samlResponse: string; } /** * Checks whether request query includes SAML request from IdP. * @param query Parsed HTTP request query. */ -function isSAMLRequestQuery(query: any): query is SAMLRequestQuery { +export function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { return query && query.SAMLRequest; } @@ -86,13 +65,59 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.realm = samlOptions.realm; } + /** + * Performs initial login request using SAMLResponse payload. + * @param request Request instance. + * @param attempt Login attempt description. + * @param [state] Optional state object associated with the provider. + */ + public async login( + request: KibanaRequest, + { samlResponse }: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.logger.debug('Trying to perform a login.'); + + const authenticationResult = state + ? await this.authenticateViaState(request, state) + : AuthenticationResult.notHandled(); + + // Let's check if user is redirected to Kibana from IdP with valid SAMLResponse. + if (authenticationResult.notHandled()) { + return await this.loginWithSAMLResponse(request, samlResponse, state); + } + + if (authenticationResult.succeeded()) { + // If user has been authenticated via session, but request also includes SAML payload + // we should check whether this payload is for the exactly same user and if not + // we'll re-authenticate user and forward to a page with the respective warning. + return await this.loginWithNewSAMLResponse( + request, + samlResponse, + (authenticationResult.state || state) as ProviderState, + authenticationResult.user as AuthenticatedUser + ); + } + + if (authenticationResult.redirected()) { + this.logger.debug('Login has been successfully performed.'); + } else { + this.logger.debug( + `Failed to perform a login: ${authenticationResult.error && + authenticationResult.error.message}` + ); + } + + return authenticationResult; + } + /** * Performs SAML request authentication. * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. let { @@ -104,11 +129,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return authenticationResult; } - if (request.loginAttempt().getCredentials() != null) { - this.debug('Login attempt is detected, but it is not supported by the provider'); - return AuthenticationResult.notHandled(); - } - if (state && authenticationResult.notHandled()) { authenticationResult = await this.authenticateViaState(request, state); if ( @@ -119,22 +139,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } } - // Let's check if user is redirected to Kibana from IdP with valid SAMLResponse. - if (isRequestWithSAMLResponsePayload(request)) { - if (authenticationResult.notHandled()) { - authenticationResult = await this.authenticateViaPayload(request, state); - } else if (authenticationResult.succeeded()) { - // If user has been authenticated via session, but request also includes SAML payload - // we should check whether this payload is for the exactly same user and if not - // we'll re-authenticate user and forward to a page with the respective warning. - authenticationResult = await this.authenticateViaNewPayload( - request, - (authenticationResult.state || state) as ProviderState, - authenticationResult.user as AuthenticatedUser - ); - } - } - // If we couldn't authenticate by means of all methods above, let's try to // initiate SAML handshake, otherwise just return authentication result we have. return authenticationResult.notHandled() @@ -147,11 +151,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state?: ProviderState) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state?: ProviderState) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if ((!state || !state.accessToken) && !isSAMLRequestQuery(request.query)) { - this.debug('There is neither access token nor SAML session to invalidate.'); + this.logger.debug('There is neither access token nor SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } @@ -164,13 +168,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // supports SAML Single Logout and we should redirect user to the specified // location to properly complete logout. if (redirect != null) { - this.debug('Redirecting user to Identity Provider to complete logout.'); + this.logger.debug('Redirecting user to Identity Provider to complete logout.'); return DeauthenticationResult.redirectTo(redirect); } return DeauthenticationResult.redirectTo('/logged_out'); } catch (err) { - this.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); } } @@ -180,18 +184,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -199,12 +203,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const user = await this.getUser(request); - this.debug('Request has been authenticated via header.'); + this.logger.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } @@ -222,13 +226,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * that was requested before SAML handshake or to default Kibana location in case of IdP * initiated login. * @param request Request instance. + * @param samlResponse SAMLResponse payload string. * @param [state] Optional state object associated with the provider. */ - private async authenticateViaPayload( - request: RequestWithSAMLPayload, + private async loginWithSAMLResponse( + request: KibanaRequest, + samlResponse: string, state?: ProviderState | null ) { - this.debug('Trying to authenticate via SAML response payload.'); + this.logger.debug('Trying to log in with SAML response payload.'); // If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information, // then something unexpected happened and we should fail. @@ -238,16 +244,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }; if (state && (!stateRequestId || !stateRedirectURL)) { const message = 'SAML response state does not have corresponding request id or redirect URL.'; - this.debug(message); - + this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } // When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login. - this.debug( + this.logger.debug( stateRequestId - ? 'Authentication has been previously initiated by Kibana.' - : 'Authentication has been initiated by Identity Provider.' + ? 'Login has been previously initiated by Kibana.' + : 'Login has been initiated by Identity Provider.' ); try { @@ -256,20 +261,20 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { const { access_token: accessToken, refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.samlAuthenticate', { + } = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { body: { ids: stateRequestId ? [stateRequestId] : [], - content: request.payload.SAMLResponse, + content: samlResponse, }, }); - this.debug('Request has been authenticated via SAML response.'); - return AuthenticationResult.redirectTo(stateRedirectURL || `${this.options.basePath}/`, { - accessToken, - refreshToken, - }); + this.logger.debug('Login has been performed with SAML response.'); + return AuthenticationResult.redirectTo( + stateRedirectURL || `${this.options.basePath.get(request)}/`, + { accessToken, refreshToken } + ); } catch (err) { - this.debug(`Failed to authenticate request via SAML response: ${err.message}`); + this.logger.debug(`Failed to log in with SAML response: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -283,24 +288,28 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * we detect that user from existing session isn't the same as defined in SAML payload. In this case * we'll forward user to a page with the respective warning. * @param request Request instance. + * @param samlResponse SAMLResponse payload string. * @param existingState State existing user session is based on. * @param user User returned for the existing session. */ - private async authenticateViaNewPayload( - request: RequestWithSAMLPayload, + private async loginWithNewSAMLResponse( + request: KibanaRequest, + samlResponse: string, existingState: ProviderState, user: AuthenticatedUser ) { - this.debug('Trying to authenticate via SAML response payload with existing valid session.'); + this.logger.debug('Trying to log in with SAML response payload and existing valid session.'); // First let's try to authenticate via SAML Response payload. - const payloadAuthenticationResult = await this.authenticateViaPayload(request); + const payloadAuthenticationResult = await this.loginWithSAMLResponse(request, samlResponse); if (payloadAuthenticationResult.failed()) { return payloadAuthenticationResult; - } else if (!payloadAuthenticationResult.shouldUpdateState()) { + } + + if (!payloadAuthenticationResult.shouldUpdateState()) { // Should never happen, but if it does - it's a bug. return AuthenticationResult.failed( - new Error('Authentication via SAML payload did not produce access and refresh tokens.') + new Error('Login with SAML payload did not produce access and refresh tokens.') ); } @@ -311,7 +320,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { const newUserAuthenticationResult = await this.authenticateViaState(request, newState); if (newUserAuthenticationResult.failed()) { return newUserAuthenticationResult; - } else if (newUserAuthenticationResult.user === undefined) { + } + + if (newUserAuthenticationResult.user === undefined) { // Should never happen, but if it does - it's a bug. return AuthenticationResult.failed( new Error('Could not retrieve user information using tokens produced for the SAML payload.') @@ -320,13 +331,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // Now let's invalidate tokens from the existing session. try { - this.debug('Perform IdP initiated local logout.'); + this.logger.debug('Perform IdP initiated local logout.'); await this.options.tokens.invalidate({ accessToken: existingState.accessToken!, refreshToken: existingState.refreshToken!, }); } catch (err) { - this.debug(`Failed to perform IdP initiated local logout: ${err.message}`); + this.logger.debug(`Failed to perform IdP initiated local logout: ${err.message}`); return AuthenticationResult.failed(err); } @@ -334,19 +345,16 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { newUserAuthenticationResult.user.username !== user.username || newUserAuthenticationResult.user.authentication_realm.name !== user.authentication_realm.name ) { - this.debug( - 'Authentication initiated by Identity Provider is for a different user than currently authenticated.' + this.logger.debug( + 'Login initiated by Identity Provider is for a different user than currently authenticated.' ); - return AuthenticationResult.redirectTo( - `${this.options.basePath}/overwritten_session`, + `${this.options.basePath.get(request)}/overwritten_session`, newState ); } - this.debug( - 'Authentication initiated by Identity Provider is for currently authenticated user.' - ); + this.logger.debug('Login initiated by Identity Provider is for currently authenticated user.'); return payloadAuthenticationResult; } @@ -356,34 +364,22 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); if (!accessToken) { - this.debug('Access token is not found in state.'); + this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } - request.headers.authorization = `Bearer ${accessToken}`; - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.debug(`Failed to authenticate request via state: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -396,13 +392,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, + request: KibanaRequest, { refreshToken }: ProviderState ) { - this.debug('Trying to refresh access token.'); + this.logger.debug('Trying to refresh access token.'); if (!refreshToken) { - this.debug('Refresh token is not found in state.'); + this.logger.debug('Refresh token is not found in state.'); return AuthenticationResult.notHandled(); } @@ -420,7 +416,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.debug('Both access and refresh tokens are expired. Re-initiating SAML handshake.'); + this.logger.debug( + 'Both access and refresh tokens are expired. Re-initiating SAML handshake.' + ); return this.authenticateViaHandshake(request); } @@ -430,22 +428,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); return AuthenticationResult.failed(err); } } @@ -454,32 +445,31 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Tries to start SAML handshake and eventually receive a token. * @param request Request instance. */ - private async authenticateViaHandshake(request: RequestWithLoginAttempt) { - this.debug('Trying to initiate SAML handshake.'); + private async authenticateViaHandshake(request: KibanaRequest) { + this.logger.debug('Trying to initiate SAML handshake.'); // If client can't handle redirect response, we shouldn't initiate SAML handshake. if (!canRedirectRequest(request)) { - this.debug('SAML handshake can not be initiated by AJAX requests.'); + this.logger.debug('SAML handshake can not be initiated by AJAX requests.'); return AuthenticationResult.notHandled(); } try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/prepare`. - const { id: requestId, redirect } = await this.options.client.callWithInternalUser( + const { id: requestId, redirect } = await this.options.client.callAsInternalUser( 'shield.samlPrepare', { body: { realm: this.realm } } ); - this.debug('Redirecting to Identity Provider with SAML request.'); - + this.logger.debug('Redirecting to Identity Provider with SAML request.'); return AuthenticationResult.redirectTo( redirect, // Store request id in the state so that we can reuse it once we receive `SAMLResponse`. - { requestId, nextURL: `${request.getBasePath()}${request.url.path}` } + { requestId, nextURL: `${this.options.basePath.get(request)}${request.url.path}` } ); } catch (err) { - this.debug(`Failed to initiate SAML handshake: ${err.message}`); + this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -490,15 +480,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param refreshToken Refresh token to invalidate. */ private async performUserInitiatedSingleLogout(accessToken: string, refreshToken: string) { - this.debug('Single logout has been initiated by the user.'); + this.logger.debug('Single logout has been initiated by the user.'); // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/logout`. - const { redirect } = await this.options.client.callWithInternalUser('shield.samlLogout', { + const { redirect } = await this.options.client.callAsInternalUser('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - this.debug('User session has been successfully invalidated.'); + this.logger.debug('User session has been successfully invalidated.'); return redirect; } @@ -508,12 +498,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Provider and redirects user back to the Identity Provider if needed. * @param request Request instance. */ - private async performIdPInitiatedSingleLogout(request: Legacy.Request) { - this.debug('Single logout has been initiated by the Identity Provider.'); + private async performIdPInitiatedSingleLogout(request: KibanaRequest) { + this.logger.debug('Single logout has been initiated by the Identity Provider.'); // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`. - const { redirect } = await this.options.client.callWithInternalUser('shield.samlInvalidate', { + const { redirect } = await this.options.client.callAsInternalUser('shield.samlInvalidate', { // Elasticsearch expects `queryString` without leading `?`, so we should strip it with `slice`. body: { queryString: request.url.search ? request.url.search.slice(1) : '', @@ -521,16 +511,8 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }, }); - this.debug('User session has been successfully invalidated.'); + this.logger.debug('User session has been successfully invalidated.'); return redirect; } - - /** - * Logs message with `debug` level and saml/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'saml'], message); - } } diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 4cb088aa00d0bc..324d42a982f8cf 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -7,23 +7,99 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; import sinon from 'sinon'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; -import { mockAuthenticationProviderOptions } from './base.mock'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { requestFixture } from '../../__fixtures__'; +import { + MockAuthenticationProviderOptions, + mockAuthenticationProviderOptions, + mockScopedClusterClient, +} from './base.mock'; import { TokenAuthenticationProvider } from './token'; describe('TokenAuthenticationProvider', () => { let provider: TokenAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - let callWithInternalUser: sinon.SinonStub; - let tokens: ReturnType['tokens']; + let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - const providerOptions = mockAuthenticationProviderOptions(); - callWithRequest = providerOptions.client.callWithRequest; - callWithInternalUser = providerOptions.client.callWithInternalUser; - tokens = providerOptions.tokens; + mockOptions = mockAuthenticationProviderOptions(); + provider = new TokenAuthenticationProvider(mockOptions); + }); + + describe('`login` method', () => { + it('succeeds with valid login attempt, creates session and authHeaders', async () => { + const request = requestFixture(); + const user = mockAuthenticatedUser(); + + const credentials = { username: 'user', password: 'password' }; + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + mockOptions.client.callAsInternalUser + .withArgs('shield.getAccessToken', { + body: { grant_type: 'password', ...credentials }, + }) + .resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken }); + + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + const authenticationResult = await provider.login(request, credentials); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + expect(authenticationResult.state).toEqual(tokenPair); + expect(authenticationResult.authHeaders).toEqual({ authorization }); + }); + + it('fails if token cannot be generated during login attempt', async () => { + const request = requestFixture(); + const credentials = { username: 'user', password: 'password' }; - provider = new TokenAuthenticationProvider(providerOptions); + const authenticationError = new Error('Invalid credentials'); + mockOptions.client.callAsInternalUser + .withArgs('shield.getAccessToken', { + body: { grant_type: 'password', ...credentials }, + }) + .rejects(authenticationError); + + const authenticationResult = await provider.login(request, credentials); + + sinon.assert.notCalled(mockOptions.client.asScoped); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.user).toBeUndefined(); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.error).toEqual(authenticationError); + }); + + it('fails if user cannot be retrieved during login attempt', async () => { + const request = requestFixture(); + const credentials = { username: 'user', password: 'password' }; + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + mockOptions.client.callAsInternalUser + .withArgs('shield.getAccessToken', { + body: { grant_type: 'password', ...credentials }, + }) + .resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken }); + + const authenticationError = new Error('Some error'); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(authenticationError); + + const authenticationResult = await provider.login(request, credentials); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.user).toBeUndefined(); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.error).toEqual(authenticationError); + }); }); describe('`authenticate` method', () => { @@ -40,78 +116,41 @@ describe('TokenAuthenticationProvider', () => { it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { const authenticationResult = await provider.authenticate( - requestFixture({ path: '/some-path # that needs to be encoded', basePath: '/s/foo' }), + requestFixture({ path: '/s/foo/some-path # that needs to be encoded' }), null ); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe( - '/base-path/login?next=%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' ); }); - it('succeeds with valid login attempt and stores in session', async () => { - const user = { username: 'user' }; - const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - callWithInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'password', username: 'user', password: 'password' }, - }) - .resolves({ access_token: 'foo', refresh_token: 'bar' }); - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toEqual({ accessToken: 'foo', refreshToken: 'bar' }); - expect(request.headers.authorization).toEqual(`Bearer foo`); - sinon.assert.calledOnce(callWithRequest); - }); - - it('succeeds if only `authorization` header is available.', async () => { + it('succeeds if only `authorization` header is available and returns neither state nor authHeaders.', async () => { const authorization = 'Bearer foo'; const request = requestFixture({ headers: { authorization } }); - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); const authenticationResult = await provider.authenticate(request); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); - sinon.assert.calledOnce(callWithRequest); - }); - - it('does not return session state for header-based auth', async () => { - const authorization = 'Bearer foo'; - const request = requestFixture({ headers: { authorization } }); - const user = { username: 'user' }; - - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request); - + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); }); it('succeeds if only state is available.', async () => { const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const authorization = `Bearer ${tokenPair.accessToken}`; - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -119,56 +158,57 @@ describe('TokenAuthenticationProvider', () => { expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toBeUndefined(); - sinon.assert.calledOnce(callWithRequest); + expect(authenticationResult.authHeaders).toEqual({ authorization }); + expect(request.headers).not.toHaveProperty('authorization'); }); it('succeeds with valid session even if requiring a token refresh', async () => { - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh + mockOptions.tokens.refresh .withArgs(tokenPair.refreshToken) .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer newfoo' } }), - 'shield.authenticate' - ) - .returns(user); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer newfoo' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledTwice(callWithRequest); - sinon.assert.calledOnce(tokens.refresh); + sinon.assert.calledOnce(mockOptions.tokens.refresh); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' }); - expect(request.headers.authorization).toEqual('Bearer newfoo'); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' }); + expect(request.headers).not.toHaveProperty('authorization'); }); it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { const request = requestFixture({ headers: { authorization: 'Basic ***' } }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); const authorization = `Bearer ${tokenPair.accessToken}`; - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.notCalled(callWithRequest); + sinon.assert.notCalled(mockOptions.client.asScoped); expect(request.headers.authorization).toBe('Basic ***'); expect(authenticationResult.notHandled()).toBe(true); }); @@ -177,83 +217,33 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer foo-from-header`; const request = requestFixture({ headers: { authorization } }); - const user = { username: 'user' }; + const user = mockAuthenticatedUser(); // GetUser will be called with request's `authorization` header. - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); const authenticationResult = await provider.authenticate(request, tokenPair); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toBeUndefined(); - sinon.assert.calledOnce(callWithRequest); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(request.headers.authorization).toEqual('Bearer foo-from-header'); }); - it('fails if token cannot be generated during login attempt', async () => { - const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - const authenticationError = new Error('Invalid credentials'); - callWithInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'password', username: 'user', password: 'password' }, - }) - .rejects(authenticationError); - - const authenticationResult = await provider.authenticate(request); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.notCalled(callWithRequest); - - expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); - }); - - it('fails if user cannot be retrieved during login attempt', async () => { - const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - callWithInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'password', username: 'user', password: 'password' }, - }) - .resolves({ access_token: 'foo', refresh_token: 'bar' }); - - const authenticationError = new Error('Some error'); - callWithRequest.withArgs(request, 'shield.authenticate').rejects(authenticationError); - - const authenticationResult = await provider.authenticate(request); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledOnce(callWithRequest); - - expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); - }); - it('fails if authentication with token from header fails with unknown error', async () => { const authorization = `Bearer foo`; const request = requestFixture({ headers: { authorization } }); const authenticationError = new errors.InternalServerError('something went wrong'); - callWithRequest.withArgs(request, 'shield.authenticate').rejects(authenticationError); + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(authenticationError); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledOnce(callWithRequest); - expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); @@ -265,17 +255,15 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture(); const authenticationError = new errors.InternalServerError('something went wrong'); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(authenticationError); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(callWithRequest); - expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); @@ -287,20 +275,19 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); const refreshError = new errors.InternalServerError('failed to refresh token'); - tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshError); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshError); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(callWithRequest); - sinon.assert.calledOnce(tokens.refresh); + sinon.assert.calledOnce(mockOptions.tokens.refresh); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); @@ -313,26 +300,27 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture({ path: '/some-path' }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 500, body: { error: { reason: 'token document is missing and must be present' } }, }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(callWithRequest); - sinon.assert.calledOnce(tokens.refresh); + sinon.assert.calledOnce(mockOptions.tokens.refresh); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/login?next=%2Fsome-path'); + expect(authenticationResult.redirectURL).toBe( + '/base-path/login?next=%2Fbase-path%2Fsome-path' + ); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toEqual(null); expect(authenticationResult.error).toBeUndefined(); @@ -342,23 +330,24 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture({ path: '/some-path' }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(callWithRequest); - sinon.assert.calledOnce(tokens.refresh); + sinon.assert.calledOnce(mockOptions.tokens.refresh); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/login?next=%2Fsome-path'); + expect(authenticationResult.redirectURL).toBe( + '/base-path/login?next=%2Fbase-path%2Fsome-path' + ); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toEqual(null); expect(authenticationResult.error).toBeUndefined(); @@ -368,19 +357,18 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' }, path: '/some-path' }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(callWithRequest); - sinon.assert.calledOnce(tokens.refresh); + sinon.assert.calledOnce(mockOptions.tokens.refresh); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); @@ -395,29 +383,28 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects({ statusCode: 401 }); - tokens.refresh + mockOptions.tokens.refresh .withArgs(tokenPair.refreshToken) .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); const authenticationError = new errors.AuthenticationException('Some error'); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer newfoo' } }), - 'shield.authenticate' - ) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer newfoo' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') .rejects(authenticationError); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledTwice(callWithRequest); - sinon.assert.calledOnce(tokens.refresh); + sinon.assert.calledOnce(mockOptions.tokens.refresh); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); @@ -427,20 +414,20 @@ describe('TokenAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + describe('`logout` method', () => { it('returns `notHandled` if state is not presented.', async () => { const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - let deauthenticateResult = await provider.deauthenticate(request); + let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); - deauthenticateResult = await provider.deauthenticate(request, null); + deauthenticateResult = await provider.logout(request, null); expect(deauthenticateResult.notHandled()).toBe(true); - sinon.assert.notCalled(tokens.invalidate); + sinon.assert.notCalled(mockOptions.tokens.invalidate); - deauthenticateResult = await provider.deauthenticate(request, tokenPair); + deauthenticateResult = await provider.logout(request, tokenPair); expect(deauthenticateResult.notHandled()).toBe(false); }); @@ -449,12 +436,12 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - tokens.invalidate.withArgs(tokenPair).rejects(failureReason); + mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -464,12 +451,12 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - tokens.invalidate.withArgs(tokenPair).resolves(); + mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); @@ -479,12 +466,12 @@ describe('TokenAuthenticationProvider', () => { const request = requestFixture({ search: '?yep' }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - tokens.invalidate.withArgs(tokenPair).resolves(); + mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); - const authenticationResult = await provider.deauthenticate(request, tokenPair); + const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/base-path/login?yep'); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 8263681027c9cc..4712d46a836112 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -5,12 +5,20 @@ */ import Boom from 'boom'; -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../can_redirect_request'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { BaseAuthenticationProvider } from './base'; import { Tokens, TokenPair } from '../tokens'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; +import { canRedirectRequest } from '..'; + +/** + * Describes the parameters that are required by the provider to process the initial login request. + */ +interface ProviderLoginAttempt { + username: string; + password: string; +} /** * The state supported by the provider. @@ -22,28 +30,68 @@ type ProviderState = TokenPair; */ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { /** - * Performs token-based request authentication + * Performs initial login request using username and password. + * @param request Request instance. + * @param loginAttempt Login attempt description. + * @param [state] Optional state object associated with the provider. + */ + /** + * Performs initial login request using username and password. * @param request Request instance. + * @param attempt User credentials. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { - this.debug(`Trying to authenticate user request to ${request.url.path}.`); + public async login( + request: KibanaRequest, + { username, password }: ProviderLoginAttempt, + state?: ProviderState | null + ) { + this.logger.debug('Trying to perform a login.'); + + try { + // First attempt to exchange login credentials for an access token + const { + access_token: accessToken, + refresh_token: refreshToken, + } = await this.options.client.callAsInternalUser('shield.getAccessToken', { + body: { grant_type: 'password', username, password }, + }); - // first try from login payload - let authenticationResult = await this.authenticateViaLoginAttempt(request); + this.logger.debug('Get token API request to Elasticsearch successful'); + + // Then attempt to query for the user details using the new token + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { accessToken, refreshToken }, + }); + } catch (err) { + this.logger.debug(`Failed to perform a login: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Performs token-based request authentication + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); // if there isn't a payload, try header-based token auth - if (authenticationResult.notHandled()) { - const { - authenticationResult: headerAuthResult, - headerNotRecognized, - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return headerAuthResult; - } - authenticationResult = headerAuthResult; + const { + authenticationResult: headerAuthResult, + headerNotRecognized, + } = await this.authenticateViaHeader(request); + if (headerNotRecognized) { + return headerAuthResult; } + let authenticationResult = headerAuthResult; // if we still can't attempt auth, try authenticating via state (session token) if (authenticationResult.notHandled() && state) { authenticationResult = await this.authenticateViaState(request, state); @@ -69,25 +117,27 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async deauthenticate(request: Legacy.Request, state?: ProviderState | null) { - this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if (!state) { - this.debug('There are no access and refresh tokens to invalidate.'); + this.logger.debug('There are no access and refresh tokens to invalidate.'); return DeauthenticationResult.notHandled(); } - this.debug('Token-based logout has been initiated by the user.'); + this.logger.debug('Token-based logout has been initiated by the user.'); try { await this.options.tokens.invalidate(state); } catch (err) { - this.debug(`Failed invalidating user's access token: ${err.message}`); + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); return DeauthenticationResult.failed(err); } const queryString = request.url.search || `?msg=LOGGED_OUT`; - return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`); + return DeauthenticationResult.redirectTo( + `${this.options.basePath.get(request)}/login${queryString}` + ); } /** @@ -95,111 +145,52 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); + private async authenticateViaHeader(request: KibanaRequest) { + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; - if (!authorization) { - this.debug('Authorization header is not presented.'); + if (!authorization || typeof authorization !== 'string') { + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true }; } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const user = await this.getUser(request); - this.debug('Request has been authenticated via header.'); + this.logger.debug('Request has been authenticated via header.'); // We intentionally do not store anything in session state because token // header auth can only be used on a request by request basis. return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } - /** - * Validates whether request contains a login payload and authenticates the - * user if necessary. - * @param request Request instance. - */ - private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via login attempt.'); - - const credentials = request.loginAttempt().getCredentials(); - if (!credentials) { - this.debug('Username and password not found in payload.'); - return AuthenticationResult.notHandled(); - } - - try { - // First attempt to exchange login credentials for an access token - const { username, password } = credentials; - const { - access_token: accessToken, - refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.getAccessToken', { - body: { grant_type: 'password', username, password }, - }); - - this.debug('Get token API request to Elasticsearch successful'); - - // Then attempt to query for the user details using the new token - request.headers.authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('User has been authenticated with new access token'); - - return AuthenticationResult.succeeded(user, { accessToken, refreshToken }); - } catch (err) { - this.debug(`Failed to authenticate request via login attempt: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - - return AuthenticationResult.failed(err); - } - } - /** * Tries to extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState( - request: RequestWithLoginAttempt, - { accessToken }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); + private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); try { - request.headers.authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.debug('Request has been authenticated via state.'); - - return AuthenticationResult.succeeded(user); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.debug(`Failed to authenticate request via state: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to crash if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -212,10 +203,10 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, + request: KibanaRequest, { refreshToken }: ProviderState ) { - this.debug('Trying to refresh access token.'); + this.logger.debug('Trying to refresh access token.'); let refreshedTokenPair: TokenPair | null; try { @@ -228,7 +219,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // login page to re-authenticate, or fail if redirect isn't possible. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.debug('Clearing session since both access and refresh tokens are expired.'); + this.logger.debug('Clearing session since both access and refresh tokens are expired.'); // Set state to `null` to let `Authenticator` know that we want to clear current session. return AuthenticationResult.redirectTo(this.getLoginPageURL(request), null); @@ -240,21 +231,15 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`); - - // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, - // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. - // We can't just set `authorization` to `undefined` or `null`, we should remove this property - // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if - // it's called with this request once again down the line (e.g. in the next authentication provider). - delete request.headers.authorization; - + this.logger.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); return AuthenticationResult.failed(err); } } @@ -263,16 +248,8 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * Constructs login page URL using current url path as `next` query string parameter. * @param request Request instance. */ - private getLoginPageURL(request: RequestWithLoginAttempt) { - const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`); - return `${this.options.basePath}/login?next=${nextURL}`; - } - - /** - * Logs message with `debug` level and token/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'token'], message); + private getLoginPageURL(request: KibanaRequest) { + const nextURL = encodeURIComponent(`${this.options.basePath.get(request)}${request.url.path}`); + return `${this.options.basePath.get(request)}/login?next=${nextURL}`; } } diff --git a/x-pack/plugins/security/server/authentication/session.test.ts b/x-pack/plugins/security/server/authentication/session.test.ts deleted file mode 100644 index fd42cbcce7c9d6..00000000000000 --- a/x-pack/plugins/security/server/authentication/session.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import sinon from 'sinon'; -import { requestFixture } from '../__tests__/__fixtures__/request'; -import { serverFixture } from '../__tests__/__fixtures__/server'; -import { Session } from './session'; - -describe('Session', () => { - const sandbox = sinon.createSandbox(); - - let server: ReturnType; - let config: { get: sinon.SinonStub }; - - beforeEach(() => { - server = serverFixture(); - config = { get: sinon.stub() }; - - server.config.returns(config); - - sandbox.useFakeTimers(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('constructor', () => { - it('correctly setups Hapi plugin.', async () => { - config.get.withArgs('xpack.security.cookieName').returns('cookie-name'); - config.get.withArgs('xpack.security.encryptionKey').returns('encryption-key'); - config.get.withArgs('xpack.security.secureCookies').returns('secure-cookies'); - config.get.withArgs('server.basePath').returns('base/path'); - - await Session.create(server as any); - - sinon.assert.calledOnce(server.auth.strategy); - sinon.assert.calledWithExactly(server.auth.strategy, 'security-cookie', 'cookie', { - cookie: 'cookie-name', - password: 'encryption-key', - clearInvalid: true, - validateFunc: sinon.match.func, - isHttpOnly: true, - isSecure: 'secure-cookies', - isSameSite: false, - path: 'base/path/', - }); - }); - }); - - describe('`get` method', () => { - let session: Session; - beforeEach(async () => { - session = await Session.create(server as any); - }); - - it('fails if request is not provided.', async () => { - await expect(session.get(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('logs the reason of validation function failure.', async () => { - const request = requestFixture(); - const failureReason = new Error('Invalid cookie.'); - server.auth.test.withArgs('security-cookie', request).rejects(failureReason); - - await expect(session.get(request)).resolves.toBeNull(); - sinon.assert.calledOnce(server.log); - sinon.assert.calledWithExactly( - server.log, - ['debug', 'security', 'auth', 'session'], - failureReason - ); - }); - - it('returns session if single session cookie is in an array.', async () => { - const request = requestFixture(); - const sessionValue = { token: 'token' }; - const sessions = [{ value: sessionValue }]; - server.auth.test.withArgs('security-cookie', request).resolves(sessions); - - await expect(session.get(request)).resolves.toBe(sessionValue); - }); - - it('returns null if multiple session cookies are detected.', async () => { - const request = requestFixture(); - const sessions = [{ value: { token: 'token' } }, { value: { token: 'token' } }]; - server.auth.test.withArgs('security-cookie', request).resolves(sessions); - - await expect(session.get(request)).resolves.toBeNull(); - }); - - it('returns what validation function returns', async () => { - const request = requestFixture(); - const rawSessionValue = { value: { token: 'token' } }; - server.auth.test.withArgs('security-cookie', request).resolves(rawSessionValue); - - await expect(session.get(request)).resolves.toEqual(rawSessionValue.value); - }); - - it('correctly process session expiration date', async () => { - const { validateFunc } = server.auth.strategy.firstCall.args[2]; - const currentTime = 100; - - sandbox.clock.tick(currentTime); - - const sessionWithoutExpires = { token: 'token' }; - let result = validateFunc({}, sessionWithoutExpires); - - expect(result.valid).toBe(true); - - const notExpiredSession = { token: 'token', expires: currentTime + 1 }; - result = validateFunc({}, notExpiredSession); - - expect(result.valid).toBe(true); - - const expiredSession = { token: 'token', expires: currentTime - 1 }; - result = validateFunc({}, expiredSession); - - expect(result.valid).toBe(false); - }); - }); - - describe('`set` method', () => { - let session: Session; - beforeEach(async () => { - session = await Session.create(server as any); - }); - - it('fails if request is not provided.', async () => { - await expect(session.set(undefined as any, undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('does not set expires if corresponding config value is not specified.', async () => { - const sessionValue = { token: 'token' }; - const request = requestFixture(); - - await session.set(request, sessionValue); - - sinon.assert.calledOnce(request.cookieAuth.set); - sinon.assert.calledWithExactly(request.cookieAuth.set, { - value: sessionValue, - expires: undefined, - }); - }); - - it('sets expires based on corresponding config value.', async () => { - const sessionValue = { token: 'token' }; - const request = requestFixture(); - - config.get.withArgs('xpack.security.sessionTimeout').returns(100); - sandbox.clock.tick(1000); - - const sessionWithTimeout = await Session.create(server as any); - await sessionWithTimeout.set(request, sessionValue); - - sinon.assert.calledOnce(request.cookieAuth.set); - sinon.assert.calledWithExactly(request.cookieAuth.set, { - value: sessionValue, - expires: 1100, - }); - }); - }); - - describe('`clear` method', () => { - let session: Session; - beforeEach(async () => { - session = await Session.create(server as any); - }); - - it('fails if request is not provided.', async () => { - await expect(session.clear(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('correctly clears cookie', async () => { - const request = requestFixture(); - - await session.clear(request); - - sinon.assert.calledOnce(request.cookieAuth.clear); - }); - }); -}); diff --git a/x-pack/plugins/security/server/authentication/session.ts b/x-pack/plugins/security/server/authentication/session.ts deleted file mode 100644 index 89b256ad2f68c7..00000000000000 --- a/x-pack/plugins/security/server/authentication/session.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import hapiAuthCookie from 'hapi-auth-cookie'; -import { Legacy } from 'kibana'; - -const HAPI_STRATEGY_NAME = 'security-cookie'; -// Forbid applying of Hapi authentication strategies to routes automatically. -const HAPI_STRATEGY_MODE = false; - -/** - * The shape of the session that is actually stored in the cookie. - */ -interface InternalSession { - /** - * Session value that is fed to the authentication provider. The shape is unknown upfront and - * entirely determined by the authentication provider that owns the current session. - */ - value: unknown; - - /** - * The Unix time in ms when the session should be considered expired. If `null`, session will stay - * active until the browser is closed. - */ - expires: number | null; -} - -function assertRequest(request: Legacy.Request) { - if (!request || typeof request !== 'object') { - throw new Error(`Request should be a valid object, was [${typeof request}].`); - } -} - -/** - * Manages Kibana user session. - */ -export class Session { - /** - * Session duration in ms. If `null` session will stay active until the browser is closed. - */ - private readonly ttl: number | null = null; - - /** - * Instantiates Session. Constructor is not supposed to be used directly. To make sure that all - * `Session` dependencies/plugins are properly initialized one should use static `Session.create` instead. - * @param server Server instance. - */ - constructor(private readonly server: Legacy.Server) { - this.ttl = this.server.config().get('xpack.security.sessionTimeout'); - } - - /** - * Retrieves session value from the session storage (e.g. cookie). - * @param request Request instance. - */ - async get(request: Legacy.Request) { - assertRequest(request); - - try { - const session = await this.server.auth.test(HAPI_STRATEGY_NAME, request); - - // If it's not an array, just return the session value - if (!Array.isArray(session)) { - return session.value as T; - } - - // If we have an array with one value, we're good also - if (session.length === 1) { - return session[0].value as T; - } - - // Otherwise, we have more than one and won't be authing the user because we don't - // know which session identifies the actual user. There's potential to change this behavior - // to ensure all valid sessions identify the same user, or choose one valid one, but this - // is the safest option. - const warning = `Found ${session.length} auth sessions when we were only expecting 1.`; - this.server.log(['warning', 'security', 'auth', 'session'], warning); - return null; - } catch (err) { - this.server.log(['debug', 'security', 'auth', 'session'], err); - return null; - } - } - - /** - * Puts current session value into the session storage. - * @param request Request instance. - * @param value Any object that will be associated with the request. - */ - async set(request: Legacy.Request, value: unknown) { - assertRequest(request); - - request.cookieAuth.set({ - value, - expires: this.ttl && Date.now() + this.ttl, - } as InternalSession); - } - - /** - * Clears current session. - * @param request Request instance. - */ - async clear(request: Legacy.Request) { - assertRequest(request); - - request.cookieAuth.clear(); - } - - /** - * Prepares and creates a session instance. - * @param server Server instance. - */ - static async create(server: Legacy.Server) { - // Register HAPI plugin that manages session cookie and delegate parsing of the session cookie to it. - await server.register({ - plugin: hapiAuthCookie, - }); - - const config = server.config(); - const httpOnly = true; - const name = config.get('xpack.security.cookieName'); - const password = config.get('xpack.security.encryptionKey'); - const path = `${config.get('server.basePath')}/`; - const secure = config.get('xpack.security.secureCookies'); - - server.auth.strategy(HAPI_STRATEGY_NAME, 'cookie', { - cookie: name, - password, - clearInvalid: true, - validateFunc: Session.validateCookie, - isHttpOnly: httpOnly, - isSecure: secure, - isSameSite: false, - path, - }); - - if (HAPI_STRATEGY_MODE) { - server.auth.default({ - strategy: HAPI_STRATEGY_NAME, - mode: 'required', - }); - } - - return new Session(server); - } - - /** - * Validation function that is passed to hapi-auth-cookie plugin and is responsible - * only for cookie expiration time validation. - * @param request Request instance. - * @param session Session value object retrieved from cookie. - */ - private static validateCookie(request: Legacy.Request, session: InternalSession) { - if (session.expires && session.expires < Date.now()) { - return { valid: false }; - } - - return { valid: true }; - } -} diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index 9ddb1a80f4956f..21d6ac6a4ba195 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -7,22 +7,31 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; import sinon from 'sinon'; +import { ClusterClient } from '../../../../../src/core/server'; +import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { Tokens } from './tokens'; describe('Tokens', () => { let tokens: Tokens; - let callWithInternalUser: sinon.SinonStub; + let mockClusterClient: sinon.SinonStubbedInstance; beforeEach(() => { - const client = { callWithRequest: sinon.stub(), callWithInternalUser: sinon.stub() }; - const tokensOptions = { client, log: sinon.stub() }; - callWithInternalUser = tokensOptions.client.callWithInternalUser as sinon.SinonStub; + mockClusterClient = { + callAsInternalUser: sinon.stub(), + asScoped: sinon.stub(), + close: sinon.stub(), + }; + + const tokensOptions = { + client: mockClusterClient, + logger: loggingServiceMock.create().get(), + }; tokens = new Tokens(tokensOptions); }); it('isAccessTokenExpiredError() returns `true` only if token expired or its document is missing', () => { - for (const error of [ + const nonExpirationErrors = [ {}, new Error(), Boom.serverUnavailable(), @@ -33,11 +42,12 @@ describe('Tokens', () => { statusCode: 500, body: { error: { reason: 'some unknown reason' } }, }, - ]) { + ]; + for (const error of nonExpirationErrors) { expect(Tokens.isAccessTokenExpiredError(error)).toBe(false); } - for (const error of [ + const expirationErrors = [ { statusCode: 401 }, Boom.unauthorized(), new errors.AuthenticationException(), @@ -45,7 +55,8 @@ describe('Tokens', () => { statusCode: 500, body: { error: { reason: 'token document is missing and must be present' } }, }, - ]) { + ]; + for (const error of expirationErrors) { expect(Tokens.isAccessTokenExpiredError(error)).toBe(true); } }); @@ -55,7 +66,7 @@ describe('Tokens', () => { it('throws if API call fails with unknown reason', async () => { const refreshFailureReason = Boom.serverUnavailable('Server is not available'); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }) @@ -66,7 +77,7 @@ describe('Tokens', () => { it('returns `null` if refresh token is not valid', async () => { const refreshFailureReason = Boom.badRequest(); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }) @@ -77,7 +88,7 @@ describe('Tokens', () => { it('returns token pair if refresh API call succeeds', async () => { const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' }; - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }) @@ -92,120 +103,154 @@ describe('Tokens', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } }) .rejects(failureReason); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } }) .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('throws if call to delete refresh token responds with an error', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } }) .rejects(failureReason); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } }) .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('invalidates all provided tokens', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('invalidates only access token if only access token is provided', async () => { const tokenPair = { accessToken: 'foo' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); + sinon.assert.calledOnce(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); }); it('invalidates only refresh token if only refresh token is provided', async () => { const tokenPair = { refreshToken: 'foo' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledOnce(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('does not fail if none of the tokens were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 0 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 0 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('does not fail if more than one token per access or refresh token were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 5 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 5 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index 15702036ce6d56..ae77d165a2ff5d 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { ClusterClient, Logger } from '../../../../../src/core/server'; import { getErrorStatusCode } from '../errors'; /** @@ -29,12 +29,16 @@ export interface TokenPair { * various authentication providers. */ export class Tokens { + /** + * Logger instance bound to `tokens` context. + */ + private readonly logger: Logger; + constructor( - private readonly options: Readonly<{ - client: Legacy.Plugins.elasticsearch.Cluster; - log: (tags: string[], message: string) => void; - }> - ) {} + private readonly options: Readonly<{ client: PublicMethodsOf; logger: Logger }> + ) { + this.logger = options.logger; + } /** * Tries to exchange provided refresh token to a new pair of access and refresh tokens. @@ -46,15 +50,15 @@ export class Tokens { const { access_token: accessToken, refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.getAccessToken', { + } = await this.options.client.callAsInternalUser('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken }, }); - this.debug('Access token has been successfully refreshed.'); + this.logger.debug('Access token has been successfully refreshed.'); return { accessToken, refreshToken }; } catch (err) { - this.debug(`Failed to refresh access token: ${err.message}`); + this.logger.debug(`Failed to refresh access token: ${err.message}`); // There are at least two common cases when refresh token request can fail: // 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires. @@ -73,7 +77,7 @@ export class Tokens { // same refresh token multiple times yielding the same refreshed access/refresh token pair it's still possible // to hit the case when refresh token is no longer valid. if (getErrorStatusCode(err) === 400) { - this.debug('Refresh token is either expired or already used.'); + this.logger.debug('Refresh token is either expired or already used.'); return null; } @@ -88,28 +92,28 @@ export class Tokens { * @param [refreshToken] Optional refresh token to invalidate. */ public async invalidate({ accessToken, refreshToken }: Partial) { - this.debug('Invalidating access/refresh token pair.'); + this.logger.debug('Invalidating access/refresh token pair.'); let invalidationError; if (refreshToken) { let invalidatedTokensCount; try { - invalidatedTokensCount = (await this.options.client.callWithInternalUser( + invalidatedTokensCount = (await this.options.client.callAsInternalUser( 'shield.deleteAccessToken', { body: { refresh_token: refreshToken } } )).invalidated_tokens; } catch (err) { - this.debug(`Failed to invalidate refresh token: ${err.message}`); + this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); // We don't re-throw the error here to have a chance to invalidate access token if it's provided. invalidationError = err; } if (invalidatedTokensCount === 0) { - this.debug('Refresh token was already invalidated.'); + this.logger.debug('Refresh token was already invalidated.'); } else if (invalidatedTokensCount === 1) { - this.debug('Refresh token has been successfully invalidated.'); + this.logger.debug('Refresh token has been successfully invalidated.'); } else if (invalidatedTokensCount > 1) { - this.debug( + this.logger.debug( `${invalidatedTokensCount} refresh tokens were invalidated, this is unexpected.` ); } @@ -118,21 +122,23 @@ export class Tokens { if (accessToken) { let invalidatedTokensCount; try { - invalidatedTokensCount = (await this.options.client.callWithInternalUser( + invalidatedTokensCount = (await this.options.client.callAsInternalUser( 'shield.deleteAccessToken', { body: { token: accessToken } } )).invalidated_tokens; } catch (err) { - this.debug(`Failed to invalidate access token: ${err.message}`); + this.logger.debug(`Failed to invalidate access token: ${err.message}`); invalidationError = err; } if (invalidatedTokensCount === 0) { - this.debug('Access token was already invalidated.'); + this.logger.debug('Access token was already invalidated.'); } else if (invalidatedTokensCount === 1) { - this.debug('Access token has been successfully invalidated.'); + this.logger.debug('Access token has been successfully invalidated.'); } else if (invalidatedTokensCount > 1) { - this.debug(`${invalidatedTokensCount} access tokens were invalidated, this is unexpected.`); + this.logger.debug( + `${invalidatedTokensCount} access tokens were invalidated, this is unexpected.` + ); } } @@ -161,12 +167,4 @@ export class Tokens { )) ); } - - /** - * Logs message with `debug` level and tokens/security related tags. - * @param message Message to log. - */ - private debug(message: string) { - this.options.log(['debug', 'security', 'tokens'], message); - } } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 8a796641dda717..3b2423c621bad3 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -4,75 +4,254 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { validateConfig } from './config'; - -describe('Validate config', function () { - let config; - const log = sinon.stub(); - const validKey = 'd624dce49dafa1401be7f3e1182b756a'; - - beforeEach(() => { - config = { - get: sinon.stub(), - getDefault: sinon.stub(), - set: sinon.stub(), - }; - log.resetHistory(); +jest.mock('crypto', () => ({ randomBytes: jest.fn() })); + +import { first } from 'rxjs/operators'; +import { loggingServiceMock, coreMock } from '../../../../src/core/server/mocks'; +import { createConfig$, ConfigSchema } from './config'; + +describe('config schema', () => { + it('generates proper defaults', () => { + expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` +Object { + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "secureCookies": false, + "sessionTimeout": null, +} +`); + + expect(ConfigSchema.validate({}, { dist: false })).toMatchInlineSnapshot(` +Object { + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "secureCookies": false, + "sessionTimeout": null, +} +`); + + expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(` +Object { + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "secureCookies": false, + "sessionTimeout": null, +} +`); + }); + + it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => { + expect(() => + ConfigSchema.validate({ encryptionKey: 'foo' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + ); + + expect(() => + ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + ); + }); + + describe('authc.oidc', () => { + it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['oidc'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + ConfigSchema.validate({ authc: { providers: ['oidc'], oidc: {} } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + ); + }); + + it(`is valid when authc.providers is "['oidc']" and realm is specified`, async () => { + expect( + ConfigSchema.validate({ + authc: { providers: ['oidc'], oidc: { realm: 'realm-1' } }, + }).authc + ).toMatchInlineSnapshot(` +Object { + "oidc": Object { + "realm": "realm-1", + }, + "providers": Array [ + "oidc", + ], +} +`); + }); + + it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['oidc', 'basic'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + ); + }); + + it(`is valid when authc.providers is "['oidc', 'basic']" and realm is specified`, async () => { + expect( + ConfigSchema.validate({ + authc: { providers: ['oidc', 'basic'], oidc: { realm: 'realm-1' } }, + }).authc + ).toMatchInlineSnapshot(` +Object { + "oidc": Object { + "realm": "realm-1", + }, + "providers": Array [ + "oidc", + "basic", + ], +} +`); + }); + + it(`realm is not allowed when authc.providers is "['basic']"`, async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['basic'], oidc: { realm: 'realm-1' } } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc]: [authc.providers] should include \\"oidc\\"."` + ); + }); }); - it('should log a warning and set xpack.security.encryptionKey if not set', function () { - config.get.withArgs('server.ssl.key').returns('foo'); - config.get.withArgs('server.ssl.certificate').returns('bar'); - config.get.withArgs('xpack.security.secureCookies').returns(false); + describe('authc.saml', () => { + it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['saml'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.saml.realm]: expected value of type [string] but got [undefined]"` + ); - expect(() => validateConfig(config, log)).not.to.throwError(); + expect(() => + ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.saml.realm]: expected value of type [string] but got [undefined]"` + ); - sinon.assert.calledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true); - sinon.assert.calledWithMatch(log, /Generating a random key/); - sinon.assert.calledWithMatch(log, /please set xpack.security.encryptionKey/); + expect( + ConfigSchema.validate({ + authc: { providers: ['saml'], saml: { realm: 'realm-1' } }, + }).authc + ).toMatchInlineSnapshot(` +Object { + "providers": Array [ + "saml", + ], + "saml": Object { + "realm": "realm-1", + }, +} +`); + }); + + it('`realm` is not allowed if saml provider is not enabled', async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['basic'], saml: { realm: 'realm-1' } } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.saml]: [authc.providers] should include \\"saml\\"."` + ); + }); }); +}); + +describe('createConfig$()', () => { + const collectLogs = (contextMock: ReturnType) => { + return loggingServiceMock.collect(contextMock.logger as ReturnType< + typeof loggingServiceMock['create'] + >); + }; + + it('should log a warning and set xpack.security.encryptionKey if not set', async () => { + const mockRandomBytes = jest.requireMock('crypto').randomBytes; + mockRandomBytes.mockReturnValue('ab'.repeat(16)); - it('should throw error if xpack.security.encryptionKey is less than 32 characters', function () { - config.get.withArgs('xpack.security.encryptionKey').returns('foo'); + const contextMock = coreMock.createPluginInitializerContext({}); + const config = await createConfig$(contextMock, true) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'ab'.repeat(16), secureCookies: true }); - const validateConfigFn = () => validateConfig(config); - expect(validateConfigFn).to.throwException(/xpack.security.encryptionKey must be at least 32 characters/); + expect(collectLogs(contextMock).warn).toMatchInlineSnapshot(` +Array [ + Array [ + "Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml", + ], +] +`); }); - it('should log a warning if SSL is not configured', function () { - config.get.withArgs('xpack.security.encryptionKey').returns(validKey); - config.get.withArgs('xpack.security.secureCookies').returns(false); + it('should log a warning if SSL is not configured', async () => { + const contextMock = coreMock.createPluginInitializerContext({ + encryptionKey: 'a'.repeat(32), + secureCookies: false, + }); - expect(() => validateConfig(config, log)).not.to.throwError(); + const config = await createConfig$(contextMock, false) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: false }); - sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.neverCalledWith(config.set, 'xpack.security.secureCookies'); - sinon.assert.calledWithMatch(log, /Session cookies will be transmitted over insecure connections/); + expect(collectLogs(contextMock).warn).toMatchInlineSnapshot(` +Array [ + Array [ + "Session cookies will be transmitted over insecure connections. This is not recommended.", + ], +] +`); }); - it('should log a warning if SSL is not configured yet secure cookies are being used', function () { - config.get.withArgs('xpack.security.encryptionKey').returns(validKey); - config.get.withArgs('xpack.security.secureCookies').returns(true); + it('should log a warning if SSL is not configured yet secure cookies are being used', async () => { + const contextMock = coreMock.createPluginInitializerContext({ + encryptionKey: 'a'.repeat(32), + secureCookies: true, + }); - expect(() => validateConfig(config, log)).not.to.throwError(); + const config = await createConfig$(contextMock, false) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); - sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.neverCalledWith(config.set, 'xpack.security.secureCookies'); - sinon.assert.calledWithMatch(log, /SSL must be configured outside of Kibana/); + expect(collectLogs(contextMock).warn).toMatchInlineSnapshot(` +Array [ + Array [ + "Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.", + ], +] +`); }); - it('should set xpack.security.secureCookies if SSL is configured', function () { - config.get.withArgs('server.ssl.key').returns('foo'); - config.get.withArgs('server.ssl.certificate').returns('bar'); - config.get.withArgs('xpack.security.encryptionKey').returns(validKey); + it('should set xpack.security.secureCookies if SSL is configured', async () => { + const contextMock = coreMock.createPluginInitializerContext({ + encryptionKey: 'a'.repeat(32), + secureCookies: false, + }); - expect(() => validateConfig(config, log)).not.to.throwError(); + const config = await createConfig$(contextMock, true) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); - sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true); - sinon.assert.notCalled(log); + expect(collectLogs(contextMock).warn).toEqual([]); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 49c9ba94ffd57f..aefd8160d94fd9 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -4,27 +4,85 @@ * you may not use this file except in compliance with the Elastic License. */ -const crypto = require('crypto'); - -export function validateConfig(config, log) { - if (config.get('xpack.security.encryptionKey') == null) { - log('Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + - 'restart, please set xpack.security.encryptionKey in kibana.yml'); - - config.set('xpack.security.encryptionKey', crypto.randomBytes(16).toString('hex')); - } else if (config.get('xpack.security.encryptionKey').length < 32) { - throw new Error('xpack.security.encryptionKey must be at least 32 characters. Please update the key in kibana.yml.'); - } - - const isSslConfigured = config.get('server.ssl.key') != null && config.get('server.ssl.certificate') != null; - if (!isSslConfigured) { - if (config.get('xpack.security.secureCookies')) { - log('Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + - 'function properly.'); - } else { - log('Session cookies will be transmitted over insecure connections. This is not recommended.'); - } - } else { - config.set('xpack.security.secureCookies', true); - } +import crypto from 'crypto'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { schema, Type, TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext } from '../../../../src/core/server'; + +export type ConfigType = ReturnType extends Observable + ? P + : ReturnType; + +const providerOptionsSchema = (providerType: string, optionsSchema: Type) => + schema.conditional( + schema.siblingRef('providers'), + schema.arrayOf(schema.string(), { + validate: providers => (!providers.includes(providerType) ? 'error' : undefined), + }), + optionsSchema, + schema.maybe( + schema.any({ validate: () => `[authc.providers] should include "${providerType}".` }) + ) + ); + +export const ConfigSchema = schema.object( + { + cookieName: schema.string({ defaultValue: 'sid' }), + encryptionKey: schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) + ), + sessionTimeout: schema.oneOf([schema.number(), schema.literal(null)], { defaultValue: null }), + secureCookies: schema.boolean({ defaultValue: false }), + authc: schema.object({ + providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + oidc: providerOptionsSchema('oidc', schema.maybe(schema.object({ realm: schema.string() }))), + saml: providerOptionsSchema('saml', schema.maybe(schema.object({ realm: schema.string() }))), + }), + }, + // This option should be removed as soon as we entirely migrate config from legacy Security plugin. + { allowUnknowns: true } +); + +export function createConfig$(context: PluginInitializerContext, isTLSEnabled: boolean) { + return context.config.create>().pipe( + map(config => { + const logger = context.logger.get('config'); + + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.security.encryptionKey in kibana.yml' + ); + + encryptionKey = crypto.randomBytes(16).toString('hex'); + } + + let secureCookies = config.secureCookies; + if (!isTLSEnabled) { + if (secureCookies) { + logger.warn( + 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + + 'function properly.' + ); + } else { + logger.warn( + 'Session cookies will be transmitted over insecure connections. This is not recommended.' + ); + } + } else if (!secureCookies) { + secureCookies = true; + } + + return { + ...config, + encryptionKey, + secureCookies, + }; + }) + ); } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 5ba1cda9cd97df..b62cc84973960c 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -4,8 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigSchema } from './config'; +import { Plugin } from './plugin'; + // These exports are part of public Security plugin contract, any change in signature of exported -// functions or removal of exports should be considered as a breaking change. +// functions or removal of exports should be considered as a breaking change. Ideally we should +// reduce number of such exports to zero and provide everything we want to expose via Setup/Start +// run-time contracts. export { wrapError } from './errors'; export { canRedirectRequest, @@ -13,3 +19,9 @@ export { BasicCredentials, DeauthenticationResult, } from './authentication'; + +export { PluginSetupContract } from './plugin'; + +export const config = { schema: ConfigSchema }; +export const plugin = (initializerContext: PluginInitializerContext) => + new Plugin(initializerContext); diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts new file mode 100644 index 00000000000000..e6c1b9d1c27c3d --- /dev/null +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ClusterClient } from '../../../../src/core/server'; +import { coreMock } from '../../../../src/core/server/mocks'; +import { Plugin } from './plugin'; + +describe('Security Plugin', () => { + let plugin: Plugin; + let mockCoreSetup: ReturnType; + let mockClusterClient: jest.Mocked; + beforeEach(() => { + plugin = new Plugin( + coreMock.createPluginInitializerContext({ + cookieName: 'sid', + sessionTimeout: 1500, + authc: { providers: ['saml', 'token'], saml: { realm: 'saml1' } }, + }) + ); + + mockCoreSetup = coreMock.createSetup(); + mockCoreSetup.http.isTLSEnabled = true; + mockCoreSetup.http.registerAuth.mockResolvedValue({ + sessionStorageFactory: { + asScoped: jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn(), clear: jest.fn() }), + }, + }); + + mockClusterClient = { + callAsInternalUser: jest.fn(), + asScoped: jest.fn(), + close: jest.fn(), + } as any; + mockCoreSetup.elasticsearch.createClient.mockReturnValue(mockClusterClient); + }); + + describe('setup()', () => { + it('exposes proper contract', async () => { + await expect(plugin.setup(mockCoreSetup)).resolves.toMatchInlineSnapshot(` + Object { + "authc": Object { + "getCurrentUser": [Function], + "isAuthenticated": [Function], + "login": [Function], + "logout": [Function], + }, + "config": Object { + "authc": Object { + "providers": Array [ + "saml", + "token", + ], + }, + "cookieName": "sid", + "secureCookies": true, + "sessionTimeout": 1500, + }, + "registerLegacyAPI": [Function], + } + `); + }); + + it('properly creates cluster client instance', async () => { + await plugin.setup(mockCoreSetup); + + expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledTimes(1); + expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledWith('security', { + plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], + }); + }); + }); + + describe('stop()', () => { + beforeEach(async () => await plugin.setup(mockCoreSetup)); + + it('properly closes cluster client instance', async () => { + expect(mockClusterClient.close).not.toHaveBeenCalled(); + + await plugin.stop(); + + expect(mockClusterClient.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts new file mode 100644 index 00000000000000..6f70821b0ce4af --- /dev/null +++ b/x-pack/plugins/security/server/plugin.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first, map } from 'rxjs/operators'; +import { + ClusterClient, + CoreSetup, + KibanaRequest, + Logger, + PluginInitializerContext, + RecursiveReadonly, + deepFreeze, +} from '../../../../src/core/server'; +import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; +import { AuthenticatedUser } from '../common/model'; +import { Authenticator, setupAuthentication } from './authentication'; +import { createConfig$ } from './config'; + +/** + * Describes a set of APIs that is available in the legacy platform only and required by this plugin + * to function properly. + */ +export interface LegacyAPI { + xpackInfo: Pick; + isSystemAPIRequest: (request: KibanaRequest) => boolean; +} + +/** + * Describes public Security plugin contract returned at the `setup` stage. + */ +export interface PluginSetupContract { + authc: { + login: Authenticator['login']; + logout: Authenticator['logout']; + getCurrentUser: (request: KibanaRequest) => Promise; + isAuthenticated: (request: KibanaRequest) => Promise; + }; + + config: RecursiveReadonly<{ + sessionTimeout: number | null; + secureCookies: boolean; + authc: { providers: ReadonlyArray }; + }>; + + registerLegacyAPI: (legacyAPI: LegacyAPI) => void; +} + +/** + * Represents Security Plugin instance that will be managed by the Kibana plugin system. + */ +export class Plugin { + private readonly logger: Logger; + private clusterClient?: ClusterClient; + + private legacyAPI?: LegacyAPI; + private readonly getLegacyAPI = () => { + if (!this.legacyAPI) { + throw new Error('Legacy API is not registered!'); + } + return this.legacyAPI; + }; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup): Promise> { + const config = await createConfig$(this.initializerContext, core.http.isTLSEnabled) + .pipe(first()) + .toPromise(); + + this.clusterClient = await core.elasticsearch.legacy.config$ + .pipe( + first(), + map(esLegacyConfig => + core.elasticsearch.createClient('security', { + ...esLegacyConfig, + plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], + }) + ) + ) + .toPromise(); + + return deepFreeze({ + registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI), + + authc: await setupAuthentication({ + core, + config, + clusterClient: this.clusterClient, + loggers: this.initializerContext.logger, + getLegacyAPI: this.getLegacyAPI, + }), + + // We should stop exposing this config as soon as only new platform plugin consumes it. The only + // exception may be `sessionTimeout` as other parts of the app may want to know it. + config: { + sessionTimeout: config.sessionTimeout, + secureCookies: config.secureCookies, + cookieName: config.cookieName, + authc: { providers: config.authc.providers }, + }, + }); + } + + public start() { + this.logger.debug('Starting plugin'); + } + + public stop() { + this.logger.debug('Stopping plugin'); + + if (this.clusterClient) { + this.clusterClient.close(); + this.clusterClient = undefined; + } + } +} From 051a4d002495b64e68559532c339c7810d635d13 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 9 Jul 2019 10:52:46 +0200 Subject: [PATCH 05/13] Review#1: remove unused `loginAttempt` from provider iterator, rely more on RecursiveReadonly, etc. --- .../server/authentication/authenticator.ts | 26 +++++++------------ x-pack/plugins/security/server/config.test.ts | 8 ++---- x-pack/plugins/security/server/config.ts | 4 +-- x-pack/plugins/security/server/plugin.ts | 2 +- 4 files changed, 14 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index f288669b0972ba..e6bd19ccaba21d 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -94,7 +94,7 @@ const providerMap = new Map< ]); function assertRequest(request: KibanaRequest) { - if (!request || !(request instanceof KibanaRequest)) { + if (!(request instanceof KibanaRequest)) { throw new Error(`Request should be a valid "KibanaRequest" instance, was [${typeof request}].`); } } @@ -269,8 +269,7 @@ export class Authenticator { providerType, isSystemAPIRequest: this.options.isSystemAPIRequest(request), authenticationResult, - existingSession: - existingSession && existingSession.provider === providerType ? existingSession : null, + existingSession: ownsSession ? existingSession : null, }); if ( @@ -316,25 +315,20 @@ export class Authenticator { /** * Returns provider iterator where providers are sorted in the order of priority (based on the session ownership). * @param sessionValue Current session value. - * @param [loginAttempt] Optional provider login attempt. If present, login attempt always has a higher - * priority comparing to the existing session. */ private *providerIterator( - sessionValue: ProviderSession | null, - loginAttempt?: ProviderLoginAttempt + sessionValue: ProviderSession | null ): IterableIterator<[string, BaseAuthenticationProvider]> { - // If there is no session or login attempt to predict which provider to use first, let's use the order - // providers are configured in. Otherwise return provider that owns login attempt/session first, and - // only then the rest of providers. Login attempt always takes precedence over session. - const preferredProvider = - (loginAttempt && loginAttempt.provider) || (sessionValue && sessionValue.provider); - if (!preferredProvider) { + // If there is no session to predict which provider to use first, let's use the order + // providers are configured in. Otherwise return provider that owns session first, and only then the rest + // of providers. + if (!sessionValue) { yield* this.providers; } else { - yield [preferredProvider, this.providers.get(preferredProvider)!]; + yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; - for (const [providerType, provider] of this.providers.entries()) { - if (providerType !== preferredProvider) { + for (const [providerType, provider] of this.providers) { + if (providerType !== sessionValue.provider) { yield [providerType, provider]; } } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 3b2423c621bad3..2cb6c92902ecdc 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -129,9 +129,7 @@ Object { it(`realm is not allowed when authc.providers is "['basic']"`, async () => { expect(() => ConfigSchema.validate({ authc: { providers: ['basic'], oidc: { realm: 'realm-1' } } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc.oidc]: [authc.providers] should include \\"oidc\\"."` - ); + ).toThrowErrorMatchingInlineSnapshot(`"[authc.oidc]: a value wasn't expected to be present"`); }); }); @@ -168,9 +166,7 @@ Object { it('`realm` is not allowed if saml provider is not enabled', async () => { expect(() => ConfigSchema.validate({ authc: { providers: ['basic'], saml: { realm: 'realm-1' } } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc.saml]: [authc.providers] should include \\"saml\\"."` - ); + ).toThrowErrorMatchingInlineSnapshot(`"[authc.saml]: a value wasn't expected to be present"`); }); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index aefd8160d94fd9..8df8641dddbed8 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -21,9 +21,7 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = validate: providers => (!providers.includes(providerType) ? 'error' : undefined), }), optionsSchema, - schema.maybe( - schema.any({ validate: () => `[authc.providers] should include "${providerType}".` }) - ) + schema.never() ); export const ConfigSchema = schema.object( diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 6f70821b0ce4af..ee3230373184e1 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -42,7 +42,7 @@ export interface PluginSetupContract { config: RecursiveReadonly<{ sessionTimeout: number | null; secureCookies: boolean; - authc: { providers: ReadonlyArray }; + authc: { providers: string[] }; }>; registerLegacyAPI: (legacyAPI: LegacyAPI) => void; From f5795d3ceef83039cb0ca71e07968bbf510d9fca Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 10 Jul 2019 10:13:10 +0200 Subject: [PATCH 06/13] Integrate latest core changes: isTlsEnabled and get rid of legacy ES config. --- ...a-plugin-server.coresetup.elasticsearch.md | 6 +- .../kibana-plugin-server.coresetup.http.md | 36 +- .../server/kibana-plugin-server.coresetup.md | 42 +- src/core/server/index.ts | 16 +- src/core/server/plugins/plugin_context.ts | 1 - src/core/server/server.api.md | 1398 +++++++++-------- x-pack/plugins/security/server/plugin.test.ts | 2 +- x-pack/plugins/security/server/plugin.ts | 18 +- 8 files changed, 765 insertions(+), 754 deletions(-) diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md b/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md index f66cf883ebffb7..cd99eabe1e1e53 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.elasticsearch.md @@ -7,5 +7,9 @@ Signature: ```typescript -elasticsearch: ElasticsearchServiceSetup; +elasticsearch: { + adminClient$: Observable; + dataClient$: Observable; + createClient: (type: string, clientConfig?: Partial) => ClusterClient; + }; ``` diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.http.md b/docs/development/core/server/kibana-plugin-server.coresetup.http.md index 507603e2876e6e..1ab58b6c3d997e 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.http.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.http.md @@ -1,18 +1,18 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) > [http](./kibana-plugin-server.coresetup.http.md) - -## CoreSetup.http property - -Signature: - -```typescript -http: { - registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; - registerAuth: HttpServiceSetup['registerAuth']; - registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; - basePath: HttpServiceSetup['basePath']; - createNewServer: HttpServiceSetup['createNewServer']; - isTlsEnabled: HttpServiceSetup['isTlsEnabled']; - }; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) > [http](./kibana-plugin-server.coresetup.http.md) + +## CoreSetup.http property + +Signature: + +```typescript +http: { + registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; + registerAuth: HttpServiceSetup['registerAuth']; + registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; + basePath: HttpServiceSetup['basePath']; + createNewServer: HttpServiceSetup['createNewServer']; + isTlsEnabled: HttpServiceSetup['isTlsEnabled']; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.md b/docs/development/core/server/kibana-plugin-server.coresetup.md index 740488311ba2fd..bc13bad563acb2 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.md @@ -1,21 +1,21 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) - -## CoreSetup interface - -Context passed to the plugins `setup` method. - -Signature: - -```typescript -export interface CoreSetup -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | {
adminClient$: Observable<ClusterClient>;
dataClient$: Observable<ClusterClient>;
} | | -| [http](./kibana-plugin-server.coresetup.http.md) | {
registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];
registerAuth: HttpServiceSetup['registerAuth'];
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
basePath: HttpServiceSetup['basePath'];
createNewServer: HttpServiceSetup['createNewServer'];
isTlsEnabled: HttpServiceSetup['isTlsEnabled'];
} | | - + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) + +## CoreSetup interface + +Context passed to the plugins `setup` method. + +Signature: + +```typescript +export interface CoreSetup +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | {
adminClient$: Observable<ClusterClient>;
dataClient$: Observable<ClusterClient>;
createClient: (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => ClusterClient;
} | | +| [http](./kibana-plugin-server.coresetup.http.md) | {
registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];
registerAuth: HttpServiceSetup['registerAuth'];
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
basePath: HttpServiceSetup['basePath'];
createNewServer: HttpServiceSetup['createNewServer'];
isTlsEnabled: HttpServiceSetup['isTlsEnabled'];
} | | + diff --git a/src/core/server/index.ts b/src/core/server/index.ts index d1a29edc77708f..72eb80d21fd973 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -35,7 +35,12 @@ * @packageDocumentation */ -import { ElasticsearchServiceSetup } from './elasticsearch'; +import { Observable } from 'rxjs'; +import { + ClusterClient, + ElasticsearchClientConfig, + ElasticsearchServiceSetup, +} from './elasticsearch'; import { HttpServiceSetup, HttpServiceStart } from './http'; import { PluginsServiceSetup, PluginsServiceStart } from './plugins'; @@ -111,7 +116,14 @@ export { RecursiveReadonly, deepFreeze } from '../utils'; * @public */ export interface CoreSetup { - elasticsearch: ElasticsearchServiceSetup; + elasticsearch: { + adminClient$: Observable; + dataClient$: Observable; + createClient: ( + type: string, + clientConfig?: Partial + ) => ClusterClient; + }; http: { registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; registerAuth: HttpServiceSetup['registerAuth']; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index fa38c7e6dc10a9..88039238af09f1 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -116,7 +116,6 @@ export function createPluginSetupContext( adminClient$: deps.elasticsearch.adminClient$, dataClient$: deps.elasticsearch.dataClient$, createClient: deps.elasticsearch.createClient, - legacy: deps.elasticsearch.legacy, }, http: { registerOnPreAuth: deps.http.registerOnPreAuth, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 041493a1ccb231..c902d726770df5 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1,697 +1,701 @@ -## API Report File for "kibana" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import Boom from 'boom'; -import { ByteSizeValue } from '@kbn/config-schema'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; -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 { Type } from '@kbn/config-schema'; -import { TypeOf } from '@kbn/config-schema'; -import { Url } from 'url'; - -// @public (undocumented) -export type APICaller = (endpoint: string, clientParams: Record, options?: CallAPIOptions) => Promise; - -// Warning: (ae-forgotten-export) The symbol "AuthResult" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -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: (data?: Partial) => AuthResult; - redirected: (url: string) => AuthResult; - rejected: (error: Error, options?: { - statusCode?: number; - }) => AuthResult; -} - -// Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts -// -// @internal (undocumented) -export function bootstrap({ configs, cliArgs, applyConfigOverrides, features, }: BootstrapArgs): Promise; - -// @public -export interface CallAPIOptions { - signal?: AbortSignal; - wrap401Errors: boolean; -} - -// @public -export class ClusterClient { - constructor(config: ElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); - asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): ScopedClusterClient; - callAsInternalUser: (endpoint: string, clientParams?: Record, options?: CallAPIOptions | undefined) => Promise; - close(): void; - } - -// @internal (undocumented) -export class ConfigService { - // Warning: (ae-forgotten-export) The symbol "Config" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "Env" needs to be exported by the entry point index.d.ts - constructor(config$: Observable, env: Env, logger: LoggerFactory); - atPath(path: ConfigPath): Observable; - getConfig$(): Observable; - // (undocumented) - getUnusedPaths(): Promise; - // (undocumented) - getUsedPaths(): Promise; - // (undocumented) - isEnabledAtPath(path: ConfigPath): Promise; - optionalAtPath(path: ConfigPath): Observable; - // Warning: (ae-forgotten-export) The symbol "ConfigPath" needs to be exported by the entry point index.d.ts - setSchema(path: ConfigPath, schema: Type): Promise; - } - -// @public -export interface CoreSetup { - // (undocumented) - elasticsearch: ElasticsearchServiceSetup; - // (undocumented) - http: { - registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; - registerAuth: HttpServiceSetup['registerAuth']; - registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; - basePath: HttpServiceSetup['basePath']; - createNewServer: HttpServiceSetup['createNewServer']; - isTlsEnabled: HttpServiceSetup['isTlsEnabled']; - }; -} - -// @public -export interface CoreStart { -} - -// Warning: (ae-forgotten-export) The symbol "Freezable" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "deepFreeze" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export function deepFreeze(object: T): RecursiveReadonly; - -// @public -export interface DiscoveredPlugin { - readonly configPath: ConfigPath; - readonly id: PluginName; - readonly optionalPlugins: ReadonlyArray; - readonly requiredPlugins: ReadonlyArray; -} - -// Warning: (ae-forgotten-export) The symbol "ElasticsearchConfig" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export type ElasticsearchClientConfig = Pick & Pick & { - pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout']; - requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout']; - sniffInterval?: ElasticsearchConfig['sniffInterval'] | ConfigOptions['sniffInterval']; - ssl?: Partial; -}; - -// Warning: (ae-missing-release-tag) "ElasticsearchError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface ElasticsearchError extends Boom { - // (undocumented) - [code]?: string; -} - -// @public -export class ElasticsearchErrorHelpers { - // (undocumented) - static decorateNotAuthorizedError(error: Error, reason?: string): ElasticsearchError; - // (undocumented) - static isNotAuthorizedError(error: any): error is ElasticsearchError; -} - -// @public (undocumented) -export interface ElasticsearchServiceSetup { - // (undocumented) - readonly adminClient$: Observable; - readonly createClient: (type: string, clientConfig?: Partial) => ClusterClient; - // (undocumented) - readonly dataClient$: Observable; - // (undocumented) - readonly legacy: { - readonly config$: Observable; - }; -} - -// @public -export interface FakeRequest { - headers: Headers; -} - -// @public -export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; - -// @public (undocumented) -export type Headers = Record; - -// Warning: (ae-forgotten-export) The symbol "HttpServerSetup" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export interface HttpServiceSetup extends HttpServerSetup { - // Warning: (ae-forgotten-export) The symbol "HttpConfig" needs to be exported by the entry point index.d.ts - // - // (undocumented) - createNewServer: (cfg: Partial) => Promise; -} - -// @public (undocumented) -export interface HttpServiceStart { - isListening: (port: number) => boolean; -} - -// @internal (undocumented) -export interface InternalCoreSetup { - // (undocumented) - elasticsearch: ElasticsearchServiceSetup; - // (undocumented) - http: HttpServiceSetup; - // (undocumented) - plugins: PluginsServiceSetup; -} - -// @public (undocumented) -export interface InternalCoreStart { - // (undocumented) - plugins: PluginsServiceStart; -} - -// @public -export class KibanaRequest { - // @internal (undocumented) - protected readonly [requestSymbol]: Request; - 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, withoutSecretHeaders?: boolean): KibanaRequest; - readonly headers: Headers; - // (undocumented) - readonly params: Params; - // (undocumented) - readonly query: Query; - // (undocumented) - readonly route: RecursiveReadonly; - // (undocumented) - readonly url: Url; - } - -// @public -export interface KibanaRequestRoute { - // (undocumented) - method: RouteMethod | 'patch' | 'options'; - // (undocumented) - options: Required; - // (undocumented) - path: string; -} - -// @public -export type LegacyRequest = Request; - -// @public -export interface Logger { - debug(message: string, meta?: LogMeta): void; - error(errorOrMessage: string | Error, meta?: LogMeta): void; - fatal(errorOrMessage: string | Error, meta?: LogMeta): void; - info(message: string, meta?: LogMeta): void; - // @internal (undocumented) - log(record: LogRecord): void; - trace(message: string, meta?: LogMeta): void; - warn(errorOrMessage: string | Error, meta?: LogMeta): void; -} - -// @public -export interface LoggerFactory { - get(...contextParts: string[]): Logger; -} - -// @internal -export class LogLevel { - // (undocumented) - static readonly All: LogLevel; - // (undocumented) - static readonly Debug: LogLevel; - // (undocumented) - static readonly Error: LogLevel; - // (undocumented) - static readonly Fatal: LogLevel; - static fromId(level: LogLevelId): LogLevel; - // Warning: (ae-forgotten-export) The symbol "LogLevelId" needs to be exported by the entry point index.d.ts - // - // (undocumented) - readonly id: LogLevelId; - // (undocumented) - static readonly Info: LogLevel; - // (undocumented) - static readonly Off: LogLevel; - supports(level: LogLevel): boolean; - // (undocumented) - static readonly Trace: LogLevel; - // (undocumented) - readonly value: number; - // (undocumented) - static readonly Warn: LogLevel; -} - -// @public -export interface LogMeta { - // (undocumented) - [key: string]: any; -} - -// @internal -export interface LogRecord { - // (undocumented) - context: string; - // (undocumented) - error?: Error; - // (undocumented) - level: LogLevel; - // (undocumented) - message: string; - // (undocumented) - meta?: { - [name: string]: any; - }; - // (undocumented) - timestamp: Date; -} - -// Warning: (ae-forgotten-export) The symbol "OnPostAuthResult" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export type OnPostAuthHandler = (request: KibanaRequest, t: OnPostAuthToolkit) => OnPostAuthResult | Promise; - -// @public -export interface OnPostAuthToolkit { - next: () => OnPostAuthResult; - redirected: (url: string) => OnPostAuthResult; - rejected: (error: Error, options?: { - statusCode?: number; - }) => OnPostAuthResult; -} - -// Warning: (ae-forgotten-export) The symbol "OnPreAuthResult" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export type OnPreAuthHandler = (request: KibanaRequest, t: OnPreAuthToolkit) => OnPreAuthResult | Promise; - -// @public -export interface OnPreAuthToolkit { - next: () => OnPreAuthResult; - redirected: (url: string, options?: { - forward: boolean; - }) => OnPreAuthResult; - rejected: (error: Error, options?: { - statusCode?: number; - }) => OnPreAuthResult; -} - -// @public -export interface Plugin { - // (undocumented) - setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; - // (undocumented) - start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; - // (undocumented) - stop?(): void; -} - -// @public -export type PluginInitializer = {}, TPluginsStart extends Record = {}> = (core: PluginInitializerContext) => Plugin; - -// @public -export interface PluginInitializerContext { - // (undocumented) - config: { - create: () => Observable; - createIfExists: () => Observable; - }; - // (undocumented) - env: { - mode: EnvironmentMode; - }; - // (undocumented) - logger: LoggerFactory; -} - -// @public -export type PluginName = string; - -// @public (undocumented) -export interface PluginsServiceSetup { - // (undocumented) - contracts: Map; - // (undocumented) - uiPlugins: { - public: Map; - internal: Map; - }; -} - -// @public (undocumented) -export interface PluginsServiceStart { - // (undocumented) - contracts: Map; -} - -// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export type RecursiveReadonly = T extends (...args: any[]) => any ? T : T extends any[] ? RecursiveReadonlyArray : T extends object ? Readonly<{ - [K in keyof T]: RecursiveReadonly; -}> : T; - -// @public -export interface RouteConfigOptions { - authRequired?: boolean; - tags?: ReadonlyArray; -} - -// @public -export type RouteMethod = 'get' | 'post' | 'put' | 'delete'; - -// @public (undocumented) -export class Router { - constructor(path: string); - delete

(route: RouteConfig, handler: RequestHandler): void; - // Warning: (ae-forgotten-export) The symbol "RouteConfig" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "RequestHandler" needs to be exported by the entry point index.d.ts - get

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

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

(route: RouteConfig, handler: RequestHandler): void; - // Warning: (ae-forgotten-export) The symbol "RouterRoute" needs to be exported by the entry point index.d.ts - // - // (undocumented) - routes: Array>; - } - -// @public (undocumented) -export interface SavedObject { - // (undocumented) - attributes: T; - // (undocumented) - error?: { - message: string; - statusCode: number; - }; - // (undocumented) - id: string; - // (undocumented) - migrationVersion?: SavedObjectsMigrationVersion; - // (undocumented) - references: SavedObjectReference[]; - // (undocumented) - type: string; - // (undocumented) - updated_at?: string; - // (undocumented) - version?: string; -} - -// @public (undocumented) -export interface SavedObjectAttributes { - // Warning: (ae-forgotten-export) The symbol "SavedObjectAttribute" needs to be exported by the entry point index.d.ts - // - // (undocumented) - [key: string]: SavedObjectAttribute | SavedObjectAttribute[]; -} - -// @public -export interface SavedObjectReference { - // (undocumented) - id: string; - // (undocumented) - name: string; - // (undocumented) - type: string; -} - -// @public (undocumented) -export interface SavedObjectsBaseOptions { - namespace?: string; -} - -// @public (undocumented) -export interface SavedObjectsBulkCreateObject { - // (undocumented) - attributes: T; - // (undocumented) - id?: string; - // (undocumented) - migrationVersion?: SavedObjectsMigrationVersion; - // (undocumented) - references?: SavedObjectReference[]; - // (undocumented) - type: string; -} - -// @public (undocumented) -export interface SavedObjectsBulkGetObject { - fields?: string[]; - // (undocumented) - id: string; - // (undocumented) - type: string; -} - -// @public (undocumented) -export interface SavedObjectsBulkResponse { - // (undocumented) - saved_objects: Array>; -} - -// @public (undocumented) -export interface SavedObjectsBulkResponse { - // (undocumented) - saved_objects: Array>; -} - -// @internal (undocumented) -export class SavedObjectsClient { - // Warning: (ae-forgotten-export) The symbol "SavedObjectsRepository" needs to be exported by the entry point index.d.ts - constructor(repository: SavedObjectsRepository); - bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; - bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; - create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; - delete(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<{}>; - // (undocumented) - errors: typeof SavedObjectsErrorHelpers; - // (undocumented) - static errors: typeof SavedObjectsErrorHelpers; - find(options: SavedObjectsFindOptions): Promise>; - get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; - update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; -} - -// Warning: (ae-incompatible-release-tags) The symbol "SavedObjectsClientContract" is marked as @public, but its signature references "SavedObjectsClient" which is marked as @internal -// -// @public -export type SavedObjectsClientContract = Pick; - -// Warning: (ae-missing-release-tag) "SavedObjectsClientWrapperFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type SavedObjectsClientWrapperFactory = (options: SavedObjectsClientWrapperOptions) => SavedObjectsClientContract; - -// Warning: (ae-missing-release-tag) "SavedObjectsClientWrapperOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface SavedObjectsClientWrapperOptions { - // (undocumented) - client: SavedObjectsClientContract; - // (undocumented) - request: Request; -} - -// @public (undocumented) -export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { - id?: string; - // (undocumented) - migrationVersion?: SavedObjectsMigrationVersion; - overwrite?: boolean; - // (undocumented) - references?: SavedObjectReference[]; -} - -// @public (undocumented) -export class SavedObjectsErrorHelpers { - // (undocumented) - static createBadRequestError(reason?: string): DecoratedError; - // (undocumented) - static createEsAutoCreateIndexError(): DecoratedError; - // (undocumented) - static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; - // (undocumented) - static createInvalidVersionError(versionInput?: string): DecoratedError; - // (undocumented) - static createUnsupportedTypeError(type: string): DecoratedError; - // (undocumented) - static decorateBadRequestError(error: Error, reason?: string): DecoratedError; - // (undocumented) - static decorateConflictError(error: Error, reason?: string): DecoratedError; - // (undocumented) - static decorateEsUnavailableError(error: Error, reason?: string): DecoratedError; - // (undocumented) - static decorateForbiddenError(error: Error, reason?: string): DecoratedError; - // (undocumented) - static decorateGeneralError(error: Error, reason?: string): DecoratedError; - // (undocumented) - static decorateNotAuthorizedError(error: Error, reason?: string): DecoratedError; - // (undocumented) - static decorateRequestEntityTooLargeError(error: Error, reason?: string): DecoratedError; - // (undocumented) - static isBadRequestError(error: Error | DecoratedError): boolean; - // (undocumented) - static isConflictError(error: Error | DecoratedError): boolean; - // (undocumented) - static isEsAutoCreateIndexError(error: Error | DecoratedError): boolean; - // (undocumented) - static isEsUnavailableError(error: Error | DecoratedError): boolean; - // (undocumented) - static isForbiddenError(error: Error | DecoratedError): boolean; - // (undocumented) - static isInvalidVersionError(error: Error | DecoratedError): boolean; - // (undocumented) - static isNotAuthorizedError(error: Error | DecoratedError): boolean; - // (undocumented) - static isNotFoundError(error: Error | DecoratedError): boolean; - // (undocumented) - static isRequestEntityTooLargeError(error: Error | DecoratedError): boolean; - // Warning: (ae-forgotten-export) The symbol "DecoratedError" needs to be exported by the entry point index.d.ts - // - // (undocumented) - static isSavedObjectsClientError(error: any): error is DecoratedError; -} - -// @public (undocumented) -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { - // (undocumented) - defaultSearchOperator?: 'AND' | 'OR'; - // (undocumented) - fields?: string[]; - // (undocumented) - hasReference?: { - type: string; - id: string; - }; - // (undocumented) - page?: number; - // (undocumented) - perPage?: number; - // (undocumented) - search?: string; - searchFields?: string[]; - // (undocumented) - sortField?: string; - // (undocumented) - sortOrder?: string; - // (undocumented) - type?: string | string[]; -} - -// @public (undocumented) -export interface SavedObjectsFindResponse { - // (undocumented) - page: number; - // (undocumented) - per_page: number; - // (undocumented) - saved_objects: Array>; - // (undocumented) - total: number; -} - -// @public -export interface SavedObjectsMigrationVersion { - // (undocumented) - [pluginName: string]: string; -} - -// @public (undocumented) -export interface SavedObjectsService { - // Warning: (ae-forgotten-export) The symbol "ScopedSavedObjectsClientProvider" needs to be exported by the entry point index.d.ts - // - // (undocumented) - addScopedSavedObjectsClientWrapperFactory: ScopedSavedObjectsClientProvider['addClientWrapperFactory']; - // (undocumented) - getSavedObjectsRepository(...rest: any[]): any; - // (undocumented) - getScopedSavedObjectsClient: ScopedSavedObjectsClientProvider['getClient']; - // Warning: (ae-incompatible-release-tags) The symbol "SavedObjectsClient" is marked as @public, but its signature references "SavedObjectsClient" which is marked as @internal - // - // (undocumented) - SavedObjectsClient: typeof SavedObjectsClient; - // (undocumented) - types: string[]; -} - -// @public (undocumented) -export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { - // (undocumented) - references?: SavedObjectReference[]; - version?: string; -} - -// Warning: (ae-forgotten-export) The symbol "Omit" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export interface SavedObjectsUpdateResponse extends Omit, 'attributes'> { - // (undocumented) - attributes: Partial; -} - -// @public -export class ScopedClusterClient { - constructor(internalAPICaller: APICaller, scopedAPICaller: APICaller, headers?: Record | undefined); - callAsCurrentUser(endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; - callAsInternalUser(endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; - } - -// @public -export interface SessionStorage { - clear(): void; - get(): Promise; - set(sessionValue: T): void; -} - -// @public -export interface SessionStorageFactory { - // (undocumented) - asScoped: (request: KibanaRequest) => SessionStorage; -} - - -// Warnings were encountered during analysis: -// -// src/core/server/plugins/plugin_context.ts:34:10 - (ae-forgotten-export) The symbol "EnvironmentMode" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/plugins_service.ts:37:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts - -``` +## API Report File for "kibana" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import Boom from 'boom'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +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 { Type } from '@kbn/config-schema'; +import { TypeOf } from '@kbn/config-schema'; +import { Url } from 'url'; + +// @public (undocumented) +export type APICaller = (endpoint: string, clientParams: Record, options?: CallAPIOptions) => Promise; + +// Warning: (ae-forgotten-export) The symbol "AuthResult" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +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: (data?: Partial) => AuthResult; + redirected: (url: string) => AuthResult; + rejected: (error: Error, options?: { + statusCode?: number; + }) => AuthResult; +} + +// Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts +// +// @internal (undocumented) +export function bootstrap({ configs, cliArgs, applyConfigOverrides, features, }: BootstrapArgs): Promise; + +// @public +export interface CallAPIOptions { + signal?: AbortSignal; + wrap401Errors: boolean; +} + +// @public +export class ClusterClient { + constructor(config: ElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); + asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): ScopedClusterClient; + callAsInternalUser: (endpoint: string, clientParams?: Record, options?: CallAPIOptions | undefined) => Promise; + close(): void; + } + +// @internal (undocumented) +export class ConfigService { + // Warning: (ae-forgotten-export) The symbol "Config" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "Env" needs to be exported by the entry point index.d.ts + constructor(config$: Observable, env: Env, logger: LoggerFactory); + atPath(path: ConfigPath): Observable; + getConfig$(): Observable; + // (undocumented) + getUnusedPaths(): Promise; + // (undocumented) + getUsedPaths(): Promise; + // (undocumented) + isEnabledAtPath(path: ConfigPath): Promise; + optionalAtPath(path: ConfigPath): Observable; + // Warning: (ae-forgotten-export) The symbol "ConfigPath" needs to be exported by the entry point index.d.ts + setSchema(path: ConfigPath, schema: Type): Promise; + } + +// @public +export interface CoreSetup { + // (undocumented) + elasticsearch: { + adminClient$: Observable; + dataClient$: Observable; + createClient: (type: string, clientConfig?: Partial) => ClusterClient; + }; + // (undocumented) + http: { + registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; + registerAuth: HttpServiceSetup['registerAuth']; + registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; + basePath: HttpServiceSetup['basePath']; + createNewServer: HttpServiceSetup['createNewServer']; + isTlsEnabled: HttpServiceSetup['isTlsEnabled']; + }; +} + +// @public +export interface CoreStart { +} + +// Warning: (ae-forgotten-export) The symbol "Freezable" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "deepFreeze" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function deepFreeze(object: T): RecursiveReadonly; + +// @public +export interface DiscoveredPlugin { + readonly configPath: ConfigPath; + readonly id: PluginName; + readonly optionalPlugins: ReadonlyArray; + readonly requiredPlugins: ReadonlyArray; +} + +// Warning: (ae-forgotten-export) The symbol "ElasticsearchConfig" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type ElasticsearchClientConfig = Pick & Pick & { + pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout']; + requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout']; + sniffInterval?: ElasticsearchConfig['sniffInterval'] | ConfigOptions['sniffInterval']; + ssl?: Partial; +}; + +// Warning: (ae-missing-release-tag) "ElasticsearchError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ElasticsearchError extends Boom { + // (undocumented) + [code]?: string; +} + +// @public +export class ElasticsearchErrorHelpers { + // (undocumented) + static decorateNotAuthorizedError(error: Error, reason?: string): ElasticsearchError; + // (undocumented) + static isNotAuthorizedError(error: any): error is ElasticsearchError; +} + +// @public (undocumented) +export interface ElasticsearchServiceSetup { + // (undocumented) + readonly adminClient$: Observable; + readonly createClient: (type: string, clientConfig?: Partial) => ClusterClient; + // (undocumented) + readonly dataClient$: Observable; + // (undocumented) + readonly legacy: { + readonly config$: Observable; + }; +} + +// @public +export interface FakeRequest { + headers: Headers; +} + +// @public +export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; + +// @public (undocumented) +export type Headers = Record; + +// Warning: (ae-forgotten-export) The symbol "HttpServerSetup" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface HttpServiceSetup extends HttpServerSetup { + // Warning: (ae-forgotten-export) The symbol "HttpConfig" needs to be exported by the entry point index.d.ts + // + // (undocumented) + createNewServer: (cfg: Partial) => Promise; +} + +// @public (undocumented) +export interface HttpServiceStart { + isListening: (port: number) => boolean; +} + +// @internal (undocumented) +export interface InternalCoreSetup { + // (undocumented) + elasticsearch: ElasticsearchServiceSetup; + // (undocumented) + http: HttpServiceSetup; + // (undocumented) + plugins: PluginsServiceSetup; +} + +// @public (undocumented) +export interface InternalCoreStart { + // (undocumented) + plugins: PluginsServiceStart; +} + +// @public +export class KibanaRequest { + // @internal (undocumented) + protected readonly [requestSymbol]: Request; + 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, withoutSecretHeaders?: boolean): KibanaRequest; + readonly headers: Headers; + // (undocumented) + readonly params: Params; + // (undocumented) + readonly query: Query; + // (undocumented) + readonly route: RecursiveReadonly; + // (undocumented) + readonly url: Url; + } + +// @public +export interface KibanaRequestRoute { + // (undocumented) + method: RouteMethod | 'patch' | 'options'; + // (undocumented) + options: Required; + // (undocumented) + path: string; +} + +// @public +export type LegacyRequest = Request; + +// @public +export interface Logger { + debug(message: string, meta?: LogMeta): void; + error(errorOrMessage: string | Error, meta?: LogMeta): void; + fatal(errorOrMessage: string | Error, meta?: LogMeta): void; + info(message: string, meta?: LogMeta): void; + // @internal (undocumented) + log(record: LogRecord): void; + trace(message: string, meta?: LogMeta): void; + warn(errorOrMessage: string | Error, meta?: LogMeta): void; +} + +// @public +export interface LoggerFactory { + get(...contextParts: string[]): Logger; +} + +// @internal +export class LogLevel { + // (undocumented) + static readonly All: LogLevel; + // (undocumented) + static readonly Debug: LogLevel; + // (undocumented) + static readonly Error: LogLevel; + // (undocumented) + static readonly Fatal: LogLevel; + static fromId(level: LogLevelId): LogLevel; + // Warning: (ae-forgotten-export) The symbol "LogLevelId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly id: LogLevelId; + // (undocumented) + static readonly Info: LogLevel; + // (undocumented) + static readonly Off: LogLevel; + supports(level: LogLevel): boolean; + // (undocumented) + static readonly Trace: LogLevel; + // (undocumented) + readonly value: number; + // (undocumented) + static readonly Warn: LogLevel; +} + +// @public +export interface LogMeta { + // (undocumented) + [key: string]: any; +} + +// @internal +export interface LogRecord { + // (undocumented) + context: string; + // (undocumented) + error?: Error; + // (undocumented) + level: LogLevel; + // (undocumented) + message: string; + // (undocumented) + meta?: { + [name: string]: any; + }; + // (undocumented) + timestamp: Date; +} + +// Warning: (ae-forgotten-export) The symbol "OnPostAuthResult" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type OnPostAuthHandler = (request: KibanaRequest, t: OnPostAuthToolkit) => OnPostAuthResult | Promise; + +// @public +export interface OnPostAuthToolkit { + next: () => OnPostAuthResult; + redirected: (url: string) => OnPostAuthResult; + rejected: (error: Error, options?: { + statusCode?: number; + }) => OnPostAuthResult; +} + +// Warning: (ae-forgotten-export) The symbol "OnPreAuthResult" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type OnPreAuthHandler = (request: KibanaRequest, t: OnPreAuthToolkit) => OnPreAuthResult | Promise; + +// @public +export interface OnPreAuthToolkit { + next: () => OnPreAuthResult; + redirected: (url: string, options?: { + forward: boolean; + }) => OnPreAuthResult; + rejected: (error: Error, options?: { + statusCode?: number; + }) => OnPreAuthResult; +} + +// @public +export interface Plugin { + // (undocumented) + setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; + // (undocumented) + start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; + // (undocumented) + stop?(): void; +} + +// @public +export type PluginInitializer = {}, TPluginsStart extends Record = {}> = (core: PluginInitializerContext) => Plugin; + +// @public +export interface PluginInitializerContext { + // (undocumented) + config: { + create: () => Observable; + createIfExists: () => Observable; + }; + // (undocumented) + env: { + mode: EnvironmentMode; + }; + // (undocumented) + logger: LoggerFactory; +} + +// @public +export type PluginName = string; + +// @public (undocumented) +export interface PluginsServiceSetup { + // (undocumented) + contracts: Map; + // (undocumented) + uiPlugins: { + public: Map; + internal: Map; + }; +} + +// @public (undocumented) +export interface PluginsServiceStart { + // (undocumented) + contracts: Map; +} + +// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type RecursiveReadonly = T extends (...args: any[]) => any ? T : T extends any[] ? RecursiveReadonlyArray : T extends object ? Readonly<{ + [K in keyof T]: RecursiveReadonly; +}> : T; + +// @public +export interface RouteConfigOptions { + authRequired?: boolean; + tags?: ReadonlyArray; +} + +// @public +export type RouteMethod = 'get' | 'post' | 'put' | 'delete'; + +// @public (undocumented) +export class Router { + constructor(path: string); + delete

(route: RouteConfig, handler: RequestHandler): void; + // Warning: (ae-forgotten-export) The symbol "RouteConfig" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "RequestHandler" needs to be exported by the entry point index.d.ts + get

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

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

(route: RouteConfig, handler: RequestHandler): void; + // Warning: (ae-forgotten-export) The symbol "RouterRoute" needs to be exported by the entry point index.d.ts + // + // (undocumented) + routes: Array>; + } + +// @public (undocumented) +export interface SavedObject { + // (undocumented) + attributes: T; + // (undocumented) + error?: { + message: string; + statusCode: number; + }; + // (undocumented) + id: string; + // (undocumented) + migrationVersion?: SavedObjectsMigrationVersion; + // (undocumented) + references: SavedObjectReference[]; + // (undocumented) + type: string; + // (undocumented) + updated_at?: string; + // (undocumented) + version?: string; +} + +// @public (undocumented) +export interface SavedObjectAttributes { + // Warning: (ae-forgotten-export) The symbol "SavedObjectAttribute" needs to be exported by the entry point index.d.ts + // + // (undocumented) + [key: string]: SavedObjectAttribute | SavedObjectAttribute[]; +} + +// @public +export interface SavedObjectReference { + // (undocumented) + id: string; + // (undocumented) + name: string; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsBaseOptions { + namespace?: string; +} + +// @public (undocumented) +export interface SavedObjectsBulkCreateObject { + // (undocumented) + attributes: T; + // (undocumented) + id?: string; + // (undocumented) + migrationVersion?: SavedObjectsMigrationVersion; + // (undocumented) + references?: SavedObjectReference[]; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsBulkGetObject { + fields?: string[]; + // (undocumented) + id: string; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsBulkResponse { + // (undocumented) + saved_objects: Array>; +} + +// @public (undocumented) +export interface SavedObjectsBulkResponse { + // (undocumented) + saved_objects: Array>; +} + +// @internal (undocumented) +export class SavedObjectsClient { + // Warning: (ae-forgotten-export) The symbol "SavedObjectsRepository" needs to be exported by the entry point index.d.ts + constructor(repository: SavedObjectsRepository); + bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; + bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; + create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; + delete(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<{}>; + // (undocumented) + errors: typeof SavedObjectsErrorHelpers; + // (undocumented) + static errors: typeof SavedObjectsErrorHelpers; + find(options: SavedObjectsFindOptions): Promise>; + get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; + update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; +} + +// Warning: (ae-incompatible-release-tags) The symbol "SavedObjectsClientContract" is marked as @public, but its signature references "SavedObjectsClient" which is marked as @internal +// +// @public +export type SavedObjectsClientContract = Pick; + +// Warning: (ae-missing-release-tag) "SavedObjectsClientWrapperFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SavedObjectsClientWrapperFactory = (options: SavedObjectsClientWrapperOptions) => SavedObjectsClientContract; + +// Warning: (ae-missing-release-tag) "SavedObjectsClientWrapperOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SavedObjectsClientWrapperOptions { + // (undocumented) + client: SavedObjectsClientContract; + // (undocumented) + request: Request; +} + +// @public (undocumented) +export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { + id?: string; + // (undocumented) + migrationVersion?: SavedObjectsMigrationVersion; + overwrite?: boolean; + // (undocumented) + references?: SavedObjectReference[]; +} + +// @public (undocumented) +export class SavedObjectsErrorHelpers { + // (undocumented) + static createBadRequestError(reason?: string): DecoratedError; + // (undocumented) + static createEsAutoCreateIndexError(): DecoratedError; + // (undocumented) + static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; + // (undocumented) + static createInvalidVersionError(versionInput?: string): DecoratedError; + // (undocumented) + static createUnsupportedTypeError(type: string): DecoratedError; + // (undocumented) + static decorateBadRequestError(error: Error, reason?: string): DecoratedError; + // (undocumented) + static decorateConflictError(error: Error, reason?: string): DecoratedError; + // (undocumented) + static decorateEsUnavailableError(error: Error, reason?: string): DecoratedError; + // (undocumented) + static decorateForbiddenError(error: Error, reason?: string): DecoratedError; + // (undocumented) + static decorateGeneralError(error: Error, reason?: string): DecoratedError; + // (undocumented) + static decorateNotAuthorizedError(error: Error, reason?: string): DecoratedError; + // (undocumented) + static decorateRequestEntityTooLargeError(error: Error, reason?: string): DecoratedError; + // (undocumented) + static isBadRequestError(error: Error | DecoratedError): boolean; + // (undocumented) + static isConflictError(error: Error | DecoratedError): boolean; + // (undocumented) + static isEsAutoCreateIndexError(error: Error | DecoratedError): boolean; + // (undocumented) + static isEsUnavailableError(error: Error | DecoratedError): boolean; + // (undocumented) + static isForbiddenError(error: Error | DecoratedError): boolean; + // (undocumented) + static isInvalidVersionError(error: Error | DecoratedError): boolean; + // (undocumented) + static isNotAuthorizedError(error: Error | DecoratedError): boolean; + // (undocumented) + static isNotFoundError(error: Error | DecoratedError): boolean; + // (undocumented) + static isRequestEntityTooLargeError(error: Error | DecoratedError): boolean; + // Warning: (ae-forgotten-export) The symbol "DecoratedError" needs to be exported by the entry point index.d.ts + // + // (undocumented) + static isSavedObjectsClientError(error: any): error is DecoratedError; +} + +// @public (undocumented) +export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { + // (undocumented) + defaultSearchOperator?: 'AND' | 'OR'; + // (undocumented) + fields?: string[]; + // (undocumented) + hasReference?: { + type: string; + id: string; + }; + // (undocumented) + page?: number; + // (undocumented) + perPage?: number; + // (undocumented) + search?: string; + searchFields?: string[]; + // (undocumented) + sortField?: string; + // (undocumented) + sortOrder?: string; + // (undocumented) + type?: string | string[]; +} + +// @public (undocumented) +export interface SavedObjectsFindResponse { + // (undocumented) + page: number; + // (undocumented) + per_page: number; + // (undocumented) + saved_objects: Array>; + // (undocumented) + total: number; +} + +// @public +export interface SavedObjectsMigrationVersion { + // (undocumented) + [pluginName: string]: string; +} + +// @public (undocumented) +export interface SavedObjectsService { + // Warning: (ae-forgotten-export) The symbol "ScopedSavedObjectsClientProvider" needs to be exported by the entry point index.d.ts + // + // (undocumented) + addScopedSavedObjectsClientWrapperFactory: ScopedSavedObjectsClientProvider['addClientWrapperFactory']; + // (undocumented) + getSavedObjectsRepository(...rest: any[]): any; + // (undocumented) + getScopedSavedObjectsClient: ScopedSavedObjectsClientProvider['getClient']; + // Warning: (ae-incompatible-release-tags) The symbol "SavedObjectsClient" is marked as @public, but its signature references "SavedObjectsClient" which is marked as @internal + // + // (undocumented) + SavedObjectsClient: typeof SavedObjectsClient; + // (undocumented) + types: string[]; +} + +// @public (undocumented) +export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { + // (undocumented) + references?: SavedObjectReference[]; + version?: string; +} + +// Warning: (ae-forgotten-export) The symbol "Omit" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface SavedObjectsUpdateResponse extends Omit, 'attributes'> { + // (undocumented) + attributes: Partial; +} + +// @public +export class ScopedClusterClient { + constructor(internalAPICaller: APICaller, scopedAPICaller: APICaller, headers?: Record | undefined); + callAsCurrentUser(endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; + callAsInternalUser(endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; + } + +// @public +export interface SessionStorage { + clear(): void; + get(): Promise; + set(sessionValue: T): void; +} + +// @public +export interface SessionStorageFactory { + // (undocumented) + asScoped: (request: KibanaRequest) => SessionStorage; +} + + +// Warnings were encountered during analysis: +// +// src/core/server/plugins/plugin_context.ts:34:10 - (ae-forgotten-export) The symbol "EnvironmentMode" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/plugins_service.ts:37:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts + +``` diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index e6c1b9d1c27c3d..dd424a61f191d2 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -22,7 +22,7 @@ describe('Security Plugin', () => { ); mockCoreSetup = coreMock.createSetup(); - mockCoreSetup.http.isTLSEnabled = true; + mockCoreSetup.http.isTlsEnabled = true; mockCoreSetup.http.registerAuth.mockResolvedValue({ sessionStorageFactory: { asScoped: jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn(), clear: jest.fn() }), diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index ee3230373184e1..85a504ee9a4ba2 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { first, map } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { ClusterClient, CoreSetup, @@ -68,21 +68,13 @@ export class Plugin { } public async setup(core: CoreSetup): Promise> { - const config = await createConfig$(this.initializerContext, core.http.isTLSEnabled) + const config = await createConfig$(this.initializerContext, core.http.isTlsEnabled) .pipe(first()) .toPromise(); - this.clusterClient = await core.elasticsearch.legacy.config$ - .pipe( - first(), - map(esLegacyConfig => - core.elasticsearch.createClient('security', { - ...esLegacyConfig, - plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], - }) - ) - ) - .toPromise(); + this.clusterClient = core.elasticsearch.createClient('security', { + plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], + }); return deepFreeze({ registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI), From a8b237e0e345601bd5e4fc41d4571287ea2de86e Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 10 Jul 2019 16:27:57 +0200 Subject: [PATCH 07/13] Revert `deepFreeze` changes and rely on `src/core/utils`. --- .../server/kibana-plugin-server.deepfreeze.md | 22 ------------------- .../core/server/kibana-plugin-server.md | 6 ----- src/core/server/index.ts | 2 +- src/core/server/server.api.md | 6 ----- x-pack/plugins/security/server/plugin.ts | 2 +- 5 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 docs/development/core/server/kibana-plugin-server.deepfreeze.md diff --git a/docs/development/core/server/kibana-plugin-server.deepfreeze.md b/docs/development/core/server/kibana-plugin-server.deepfreeze.md deleted file mode 100644 index 5c55cc90fdca2b..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.deepfreeze.md +++ /dev/null @@ -1,22 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [deepFreeze](./kibana-plugin-server.deepfreeze.md) - -## deepFreeze() function - -Signature: - -```typescript -export declare function deepFreeze(object: T): RecursiveReadonly; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| object | T | | - -Returns: - -`RecursiveReadonly` - diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 0cf5bf7c4d4d2d..ab79f2b3829094 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -21,12 +21,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.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 | -## Functions - -| Function | Description | -| --- | --- | -| [deepFreeze(object)](./kibana-plugin-server.deepfreeze.md) | | - ## Interfaces | Interface | Description | diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 72eb80d21fd973..787478d5b3c3f8 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -108,7 +108,7 @@ export { SavedObjectsUpdateResponse, } from './saved_objects'; -export { RecursiveReadonly, deepFreeze } from '../utils'; +export { RecursiveReadonly } from '../utils'; /** * Context passed to the plugins `setup` method. diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index c902d726770df5..e4345d1310ecf6 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -106,12 +106,6 @@ export interface CoreSetup { export interface CoreStart { } -// Warning: (ae-forgotten-export) The symbol "Freezable" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "deepFreeze" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export function deepFreeze(object: T): RecursiveReadonly; - // @public export interface DiscoveredPlugin { readonly configPath: ConfigPath; diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 85a504ee9a4ba2..9e9c0aba408185 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -12,8 +12,8 @@ import { Logger, PluginInitializerContext, RecursiveReadonly, - deepFreeze, } from '../../../../src/core/server'; +import { deepFreeze } from '../../../../src/core/utils'; import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; import { AuthenticatedUser } from '../common/model'; import { Authenticator, setupAuthentication } from './authentication'; From 70e726d366dce2f5254ec6fa946eeb32498a655e Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 11 Jul 2019 14:08:09 +0200 Subject: [PATCH 08/13] Review#2: do not mutate injectedVars in onInit. Integrate latest upstream changes. --- src/core/server/mocks.ts | 2 +- x-pack/legacy/plugins/security/index.js | 24 ++++++++++--------- .../security/server/authentication/index.ts | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 9dc180e5e69c9b..af0eed6ba833d6 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -55,7 +55,7 @@ function pluginInitializerContextMock(config: T) { function createCoreSetupMock() { const mock: MockedKeys = { - elasticsearch: elasticsearchServiceMock.createSetupContract() as any, + elasticsearch: elasticsearchServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), }; diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 6b9e93f2ad0a2f..fe4558e9e7c693 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -32,7 +32,6 @@ import { deepFreeze } from './server/lib/deep_freeze'; import { createOptionalPlugin } from '../../server/lib/optional_plugin'; import { KibanaRequest } from '../../../../src/core/server'; -let defaultVars; export const security = (kibana) => new kibana.Plugin({ id: 'security', configPrefix: 'xpack.security', @@ -95,7 +94,18 @@ export const security = (kibana) => new kibana.Plugin({ 'plugins/security/hacks/on_unauthorized_response' ], home: ['plugins/security/register_feature'], - injectDefaultVars: () => defaultVars, + injectDefaultVars: (server) => { + const securityPlugin = server.newPlatform.setup.plugins.security; + if (!securityPlugin) { + throw new Error('New Platform XPack Security plugin is not available.'); + } + + return { + secureCookies: securityPlugin.config.secureCookies, + sessionTimeout: securityPlugin.config.sessionTimeout, + enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), + }; + }, }, async postInit(server) { @@ -113,7 +123,7 @@ export const security = (kibana) => new kibana.Plugin({ }, async init(server) { - const securityPlugin = this.kbnServer.newPlatform.setup.plugins.security; + const securityPlugin = server.newPlatform.setup.plugins.security; if (!securityPlugin) { throw new Error('New Platform XPack Security plugin is not available.'); } @@ -131,14 +141,6 @@ export const security = (kibana) => new kibana.Plugin({ const config = server.config(); const xpackInfoFeature = xpackInfo.feature(plugin.id); - // Config required for default injected vars is coming from new platform plugin and hence we can - // initialize these only within `init` function of the legacy plugin. - defaultVars = { - secureCookies: securityPlugin.config.secureCookies, - sessionTimeout: securityPlugin.config.sessionTimeout, - enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), - }; - // Register a function that is called whenever the xpack info changes, // to re-compute the license check results for this plugin xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 73d6735a0f7f94..64e5d5852b8542 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -81,7 +81,7 @@ export async function setupAuthentication({ if (authenticationResult.succeeded()) { return t.authenticated({ - state: authenticationResult.user, + state: (authenticationResult.user as unknown) as Record, headers: authenticationResult.authHeaders, }); } From 85ae758b3fafca29a0b6cade06f15a73bb913f86 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 12 Jul 2019 09:37:16 +0200 Subject: [PATCH 09/13] Use mocks provided by the Core. --- .../security/server/__fixtures__/index.ts | 7 - .../security/server/__fixtures__/request.ts | 37 ----- .../authentication/authenticator.test.ts | 99 ++++++------ .../can_redirect_request.test.ts | 9 +- .../server/authentication/index.test.ts | 112 ++++++------- .../security/server/authentication/index.ts | 2 +- .../authentication/providers/basic.test.ts | 39 +++-- .../authentication/providers/kerberos.test.ts | 58 ++++--- .../authentication/providers/oidc.test.ts | 52 +++--- .../authentication/providers/saml.test.ts | 80 +++++----- .../authentication/providers/token.test.ts | 53 ++++--- .../server/authentication/tokens.test.ts | 149 ++++++++---------- x-pack/plugins/security/server/config.test.ts | 14 +- x-pack/plugins/security/server/plugin.test.ts | 24 ++- 14 files changed, 351 insertions(+), 384 deletions(-) delete mode 100644 x-pack/plugins/security/server/__fixtures__/index.ts delete mode 100644 x-pack/plugins/security/server/__fixtures__/request.ts diff --git a/x-pack/plugins/security/server/__fixtures__/index.ts b/x-pack/plugins/security/server/__fixtures__/index.ts deleted file mode 100644 index c8f0d6dee273f3..00000000000000 --- a/x-pack/plugins/security/server/__fixtures__/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export { requestFixture } from './request'; diff --git a/x-pack/plugins/security/server/__fixtures__/request.ts b/x-pack/plugins/security/server/__fixtures__/request.ts deleted file mode 100644 index 869595d9aa4e1c..00000000000000 --- a/x-pack/plugins/security/server/__fixtures__/request.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import url from 'url'; -import { schema } from '@kbn/config-schema'; -import { KibanaRequest } from '../../../../../src/core/server'; - -interface RequestFixtureOptions { - headers?: Record; - params?: Record; - path?: string; - search?: string; - payload?: unknown; -} - -export function requestFixture({ - headers = { accept: 'something/html' }, - params, - path = '/wat', - search = '', - payload, -}: RequestFixtureOptions = {}) { - return KibanaRequest.from( - { - headers, - params, - url: { path, search }, - query: search ? url.parse(search, true /* parseQueryString */).query : {}, - payload, - route: { settings: {} }, - } as any, - { query: schema.object({}, { allowUnknowns: true }) } - ); -} diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 1e2595c33002d1..15f1b7cf52622d 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -9,31 +9,30 @@ jest.mock('./providers/basic', () => ({ BasicAuthenticationProvider: jest.fn() } import Boom from 'boom'; import { SessionStorage } from '../../../../../src/core/server'; -import { loggingServiceMock, httpServiceMock } from '../../../../../src/core/server/mocks'; +import { + loggingServiceMock, + httpServiceMock, + httpServerMock, + elasticsearchServiceMock, + sessionStorageMock, +} from '../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { requestFixture } from '../__fixtures__'; import { AuthenticationResult } from './authentication_result'; -import { Authenticator, AuthenticatorOptions } from './authenticator'; +import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; import { BasicAuthenticationProvider } from './providers'; function getMockOptions(config: Partial = {}) { return { - clusterClient: { callAsInternalUser: jest.fn(), asScoped: jest.fn(), close: jest.fn() }, + clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, loggers: loggingServiceMock.create(), isSystemAPIRequest: jest.fn(), config: { sessionTimeout: null, authc: { providers: [], oidc: {}, saml: {} }, ...config }, - sessionStorageFactory: { - asScoped: jest.fn().mockReturnValue(getMockSessionStorage()), - }, + sessionStorageFactory: sessionStorageMock.createFactory(), }; } -function getMockSessionStorage() { - return { get: jest.fn(), set: jest.fn(), clear: jest.fn() }; -} - describe('Authenticator', () => { let mockBasicAuthenticationProvider: jest.Mocked>; beforeEach(() => { @@ -72,10 +71,10 @@ describe('Authenticator', () => { describe('`login` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; + let mockSessionStorage: jest.Mocked>; beforeEach(() => { mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); - mockSessionStorage = getMockSessionStorage(); + mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); @@ -88,17 +87,21 @@ describe('Authenticator', () => { }); it('fails if login attempt is not provided.', async () => { - await expect(authenticator.login(requestFixture(), undefined as any)).rejects.toThrowError( + await expect( + authenticator.login(httpServerMock.createKibanaRequest(), undefined as any) + ).rejects.toThrowError( 'Login attempt should be an object with non-empty "provider" property.' ); - await expect(authenticator.login(requestFixture(), {} as any)).rejects.toThrowError( + await expect( + authenticator.login(httpServerMock.createKibanaRequest(), {} as any) + ).rejects.toThrowError( 'Login attempt should be an object with non-empty "provider" property.' ); }); it('fails if an authentication provider fails.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const failureReason = new Error('Not Authorized'); mockBasicAuthenticationProvider.login.mockResolvedValue( @@ -114,7 +117,7 @@ describe('Authenticator', () => { }); it('returns user that authentication provider returns.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const user = mockAuthenticatedUser(); mockBasicAuthenticationProvider.login.mockResolvedValue( @@ -132,7 +135,7 @@ describe('Authenticator', () => { it('creates session whenever authentication provider returns state for system API requests', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; mockOptions.isSystemAPIRequest.mockReturnValue(true); @@ -157,7 +160,7 @@ describe('Authenticator', () => { it('creates session whenever authentication provider returns state for non-system API requests', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; mockOptions.isSystemAPIRequest.mockReturnValue(false); @@ -181,7 +184,7 @@ describe('Authenticator', () => { }); it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authenticationResult = await authenticator.login(request, { provider: 'token', value: {}, @@ -193,7 +196,7 @@ describe('Authenticator', () => { const state = { authorization: 'Basic xxx' }; const user = mockAuthenticatedUser(); const credentials = { username: 'user', password: 'password' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); @@ -219,10 +222,10 @@ describe('Authenticator', () => { describe('`authenticate` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; + let mockSessionStorage: jest.Mocked>; beforeEach(() => { mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); - mockSessionStorage = getMockSessionStorage(); + mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); @@ -235,7 +238,7 @@ describe('Authenticator', () => { }); it('fails if an authentication provider fails.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const failureReason = new Error('Not Authorized'); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -248,7 +251,9 @@ describe('Authenticator', () => { }); it('returns user that authentication provider returns.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic ***' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic ***' }, + }); const user = mockAuthenticatedUser(); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -263,7 +268,7 @@ describe('Authenticator', () => { it('creates session whenever authentication provider returns state for system API requests', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; mockOptions.isSystemAPIRequest.mockReturnValue(true); @@ -285,7 +290,7 @@ describe('Authenticator', () => { it('creates session whenever authentication provider returns state for non-system API requests', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; mockOptions.isSystemAPIRequest.mockReturnValue(false); @@ -308,7 +313,7 @@ describe('Authenticator', () => { it('does not extend session for system API calls.', async () => { const user = mockAuthenticatedUser(); const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(true); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -327,7 +332,7 @@ describe('Authenticator', () => { it('extends session for non-system API calls.', async () => { const user = mockAuthenticatedUser(); const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(false); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -351,7 +356,7 @@ describe('Authenticator', () => { it('properly extends session timeout if it is defined.', async () => { const user = mockAuthenticatedUser(); const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); // Create new authenticator with non-null `sessionTimeout`. @@ -360,7 +365,7 @@ describe('Authenticator', () => { authc: { providers: ['basic'], oidc: {}, saml: {} }, }); - mockSessionStorage = getMockSessionStorage(); + mockSessionStorage = sessionStorageMock.create(); mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -387,7 +392,7 @@ describe('Authenticator', () => { it('does not touch session for system API calls if authentication fails with non-401 reason.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(true); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -404,7 +409,7 @@ describe('Authenticator', () => { it('does not touch session for non-system API calls if authentication fails with non-401 reason.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(false); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -423,7 +428,7 @@ describe('Authenticator', () => { const user = mockAuthenticatedUser(); const existingState = { authorization: 'Basic xxx' }; const newState = { authorization: 'Basic yyy' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(true); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -452,7 +457,7 @@ describe('Authenticator', () => { const user = mockAuthenticatedUser(); const existingState = { authorization: 'Basic xxx' }; const newState = { authorization: 'Basic yyy' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(false); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -479,7 +484,7 @@ describe('Authenticator', () => { it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(true); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -496,7 +501,7 @@ describe('Authenticator', () => { it('clears session if provider failed to authenticate non-system API request with 401 with active session.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(false); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -513,7 +518,7 @@ describe('Authenticator', () => { it('clears session if provider requested it via setting state to `null`.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.redirectTo('some-url', null) @@ -529,7 +534,7 @@ describe('Authenticator', () => { it('does not clear session if provider can not handle system API request authentication with active session.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(true); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -546,7 +551,7 @@ describe('Authenticator', () => { it('does not clear session if provider can not handle non-system API request authentication with active session.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(false); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -563,7 +568,7 @@ describe('Authenticator', () => { it('clears session for system API request if it belongs to not configured provider.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(true); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -580,7 +585,7 @@ describe('Authenticator', () => { it('clears session for non-system API request if it belongs to not configured provider.', async () => { const state = { authorization: 'Basic xxx' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.isSystemAPIRequest.mockReturnValue(false); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -599,10 +604,10 @@ describe('Authenticator', () => { describe('`logout` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; + let mockSessionStorage: jest.Mocked>; beforeEach(() => { mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); - mockSessionStorage = getMockSessionStorage(); + mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); @@ -615,7 +620,7 @@ describe('Authenticator', () => { }); it('returns `notHandled` if session does not exist.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockSessionStorage.get.mockResolvedValue(null); const deauthenticationResult = await authenticator.logout(request); @@ -625,7 +630,7 @@ describe('Authenticator', () => { }); it('clears session and returns whatever authentication provider returns.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const state = { authorization: 'Basic xxx' }; mockBasicAuthenticationProvider.logout.mockResolvedValue( DeauthenticationResult.redirectTo('some-url') @@ -641,7 +646,7 @@ describe('Authenticator', () => { }); it('only clears session if it belongs to not configured provider.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const state = { authorization: 'Bearer xxx' }; mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts index 5d19ee7ac9a7fa..1c9b936692f9e8 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts @@ -4,21 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { requestFixture } from '../__fixtures__'; +import { httpServerMock } from '../../../../../src/core/server/http/http_server.mocks'; + import { canRedirectRequest } from './can_redirect_request'; describe('can_redirect_request', () => { it('returns true if request does not have either a kbn-version or kbn-xsrf header', () => { - expect(canRedirectRequest(requestFixture())).toBe(true); + expect(canRedirectRequest(httpServerMock.createKibanaRequest())).toBe(true); }); it('returns false if request has a kbn-version header', () => { - const request = requestFixture({ headers: { 'kbn-version': 'something' } }); + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-version': 'something' } }); expect(canRedirectRequest(request)).toBe(false); }); it('returns false if request has a kbn-xsrf header', () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'something' } }); + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'something' } }); expect(canRedirectRequest(request)).toBe(false); }); diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 3cb968aca55f01..00520ceb999221 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -8,21 +8,31 @@ jest.mock('./authenticator'); import Boom from 'boom'; import { first } from 'rxjs/operators'; + +import { + loggingServiceMock, + coreMock, + httpServerMock, + httpServiceMock, + elasticsearchServiceMock, +} from '../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; + import { AuthenticationHandler, AuthToolkit, ClusterClient, + CoreSetup, KibanaRequest, + LoggerFactory, + ScopedClusterClient, } from '../../../../../src/core/server'; -import { loggingServiceMock, coreMock } from '../../../../../src/core/server/mocks'; import { AuthenticatedUser } from '../../common/model'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { requestFixture } from '../__fixtures__'; -import { createConfig$ } from '../config'; +import { ConfigType, createConfig$ } from '../config'; import { getErrorStatusCode } from '../errors'; import { LegacyAPI } from '../plugin'; import { AuthenticationResult } from './authentication_result'; -import { setupAuthentication, SetupAuthenticationParams } from '.'; +import { setupAuthentication } from '.'; function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> = {}) { return { @@ -34,24 +44,16 @@ function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> } describe('setupAuthentication()', () => { - let mockSetupAuthenticationParams: jest.Mocked< - Pick< - SetupAuthenticationParams, - Exclude - > - > & { - core: ReturnType; + let mockSetupAuthenticationParams: { + config: ConfigType; + loggers: LoggerFactory; + getLegacyAPI(): LegacyAPI; + core: MockedKeys; clusterClient: jest.Mocked>; }; let mockXpackInfo: jest.Mocked; + let mockScopedClusterClient: jest.Mocked>; beforeEach(async () => { - const mockCoreSetup = coreMock.createSetup(); - mockCoreSetup.http.registerAuth.mockResolvedValue({ - sessionStorageFactory: { - asScoped: jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn(), clear: jest.fn() }), - }, - }); - mockXpackInfo = { isAvailable: jest.fn().mockReturnValue(true), feature: jest.fn().mockReturnValue(mockXPackFeature()), @@ -67,12 +69,17 @@ describe('setupAuthentication()', () => { true ); mockSetupAuthenticationParams = { - core: mockCoreSetup, + core: coreMock.createSetup(), config: await mockConfig$.pipe(first()).toPromise(), - clusterClient: { callAsInternalUser: jest.fn(), asScoped: jest.fn(), close: jest.fn() }, + clusterClient: elasticsearchServiceMock.createClusterClient(), loggers: loggingServiceMock.create(), getLegacyAPI: jest.fn().mockReturnValue({ xpackInfo: mockXpackInfo }), }; + + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); }); afterEach(() => jest.clearAllMocks()); @@ -104,11 +111,7 @@ describe('setupAuthentication()', () => { let authenticate: jest.SpyInstance, [KibanaRequest]>; let mockAuthToolkit: jest.Mocked; beforeEach(async () => { - mockAuthToolkit = { - authenticated: jest.fn(), - redirected: jest.fn(), - rejected: jest.fn(), - }; + mockAuthToolkit = httpServiceMock.createAuthToolkit(); await setupAuthentication(mockSetupAuthenticationParams); @@ -118,7 +121,7 @@ describe('setupAuthentication()', () => { }); it('replies with no credentials when security is disabled in elasticsearch', async () => { - const mockRequest = requestFixture(); + const mockRequest = httpServerMock.createKibanaRequest(); mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); @@ -133,7 +136,7 @@ describe('setupAuthentication()', () => { }); it('continues request with credentials on success', async () => { - const mockRequest = requestFixture(); + const mockRequest = httpServerMock.createKibanaRequest(); const mockUser = mockAuthenticatedUser(); const mockAuthHeaders = { authorization: 'Basic xxx' }; @@ -158,7 +161,7 @@ describe('setupAuthentication()', () => { it('redirects user if redirection is requested by the authenticator', async () => { authenticate.mockResolvedValue(AuthenticationResult.redirectTo('/some/url')); - await authHandler(requestFixture(), mockAuthToolkit); + await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit); expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1); expect(mockAuthToolkit.redirected).toHaveBeenCalledWith('/some/url'); @@ -169,7 +172,7 @@ describe('setupAuthentication()', () => { it('rejects with `Internal Server Error` when `authenticate` throws unhandled exception', async () => { authenticate.mockRejectedValue(new Error('something went wrong')); - await authHandler(requestFixture(), mockAuthToolkit); + await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit); expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); const [[error]] = mockAuthToolkit.rejected.mock.calls; @@ -184,7 +187,7 @@ describe('setupAuthentication()', () => { const esError = Boom.badRequest('some message'); authenticate.mockResolvedValue(AuthenticationResult.failed(esError)); - await authHandler(requestFixture(), mockAuthToolkit); + await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit); expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); const [[error]] = mockAuthToolkit.rejected.mock.calls; @@ -203,7 +206,7 @@ describe('setupAuthentication()', () => { ] as any; authenticate.mockResolvedValue(AuthenticationResult.failed(originalEsError, ['Negotiate'])); - await authHandler(requestFixture(), mockAuthToolkit); + await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit); expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); const [[error]] = mockAuthToolkit.rejected.mock.calls; @@ -217,7 +220,7 @@ describe('setupAuthentication()', () => { it('returns `unauthorized` when authentication can not be handled', async () => { authenticate.mockResolvedValue(AuthenticationResult.notHandled()); - await authHandler(requestFixture(), mockAuthToolkit); + await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit); expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); const [[error]] = mockAuthToolkit.rejected.mock.calls; @@ -238,23 +241,23 @@ describe('setupAuthentication()', () => { it('returns `null` if Security is disabled', async () => { mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); - await expect(getCurrentUser(requestFixture())).resolves.toBe(null); + await expect(getCurrentUser(httpServerMock.createKibanaRequest())).resolves.toBe(null); }); it('fails if `authenticate` call fails', async () => { const failureReason = new Error('Something went wrong'); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ - callAsCurrentUser: jest.fn().mockRejectedValue(failureReason as any), - } as any); - await expect(getCurrentUser(requestFixture())).rejects.toBe(failureReason); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + + await expect(getCurrentUser(httpServerMock.createKibanaRequest())).rejects.toBe( + failureReason + ); }); it('returns result of `authenticate` call.', async () => { const mockUser = mockAuthenticatedUser(); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ - callAsCurrentUser: jest.fn().mockResolvedValue(mockUser as any), - } as any); - await expect(getCurrentUser(requestFixture())).resolves.toBe(mockUser); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockUser); + + await expect(getCurrentUser(httpServerMock.createKibanaRequest())).resolves.toBe(mockUser); }); }); @@ -267,31 +270,30 @@ describe('setupAuthentication()', () => { it('returns `true` if Security is disabled', async () => { mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); - await expect(isAuthenticated(requestFixture())).resolves.toBe(true); + await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(true); }); it('returns `true` if `authenticate` succeeds.', async () => { const mockUser = mockAuthenticatedUser(); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ - callAsCurrentUser: jest.fn().mockResolvedValue(mockUser as any), - } as any); - await expect(isAuthenticated(requestFixture())).resolves.toBe(true); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockUser); + + await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(true); }); it('returns `false` if `authenticate` fails with 401.', async () => { const failureReason = Boom.unauthorized(); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ - callAsCurrentUser: jest.fn().mockRejectedValue(failureReason as any), - } as any); - await expect(isAuthenticated(requestFixture())).resolves.toBe(false); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + + await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(false); }); it('fails if `authenticate` call fails with unknown reason', async () => { const failureReason = Boom.badRequest(); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue({ - callAsCurrentUser: jest.fn().mockRejectedValue(failureReason as any), - } as any); - await expect(isAuthenticated(requestFixture())).rejects.toBe(failureReason); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + + await expect(isAuthenticated(httpServerMock.createKibanaRequest())).rejects.toBe( + failureReason + ); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 64e5d5852b8542..6a534b53350c5f 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -24,7 +24,7 @@ export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; export { BasicCredentials } from './providers'; -export interface SetupAuthenticationParams { +interface SetupAuthenticationParams { core: CoreSetup; clusterClient: PublicMethodsOf; config: ConfigType; diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index c68a49ed61a347..57ec808151f704 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -5,9 +5,11 @@ */ import sinon from 'sinon'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { requestFixture } from '../../__fixtures__'; import { mockAuthenticationProviderOptions, mockScopedClusterClient } from './base.mock'; + import { BasicAuthenticationProvider, BasicCredentials } from './basic'; function generateAuthorizationHeader(username: string, password: string) { @@ -32,7 +34,6 @@ describe('BasicAuthenticationProvider', () => { describe('`login` method', () => { it('succeeds with valid login attempt, creates session and authHeaders', async () => { - const request = requestFixture(); const user = mockAuthenticatedUser(); const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); @@ -41,7 +42,10 @@ describe('BasicAuthenticationProvider', () => { .callAsCurrentUser.withArgs('shield.authenticate') .resolves(user); - const authenticationResult = await provider.login(request, credentials); + const authenticationResult = await provider.login( + httpServerMock.createKibanaRequest(), + credentials + ); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); @@ -50,7 +54,7 @@ describe('BasicAuthenticationProvider', () => { }); it('fails if user cannot be retrieved during login attempt', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); @@ -74,7 +78,7 @@ describe('BasicAuthenticationProvider', () => { // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and // avoid triggering of redirect logic. const authenticationResult = await provider.authenticate( - requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }), + httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), null ); @@ -83,7 +87,7 @@ describe('BasicAuthenticationProvider', () => { it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { const authenticationResult = await provider.authenticate( - requestFixture({ path: '/s/foo/some-path # that needs to be encoded' }), + httpServerMock.createKibanaRequest({ path: '/s/foo/some-path # that needs to be encoded' }), null ); @@ -94,12 +98,15 @@ describe('BasicAuthenticationProvider', () => { }); it('does not handle authentication if state exists, but authorization property is missing.', async () => { - const authenticationResult = await provider.authenticate(requestFixture(), {}); + const authenticationResult = await provider.authenticate( + httpServerMock.createKibanaRequest(), + {} + ); expect(authenticationResult.notHandled()).toBe(true); }); it('succeeds if only `authorization` header is available.', async () => { - const request = requestFixture({ + const request = httpServerMock.createKibanaRequest({ headers: { authorization: generateAuthorizationHeader('user', 'password') }, }); const user = mockAuthenticatedUser(); @@ -119,7 +126,7 @@ describe('BasicAuthenticationProvider', () => { }); it('succeeds if only state is available.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const user = mockAuthenticatedUser(); const authorization = generateAuthorizationHeader('user', 'password'); @@ -136,7 +143,9 @@ describe('BasicAuthenticationProvider', () => { }); it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { - const request = requestFixture({ headers: { authorization: 'Bearer ***' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer ***' }, + }); const authorization = generateAuthorizationHeader('user', 'password'); const authenticationResult = await provider.authenticate(request, { authorization }); @@ -147,7 +156,7 @@ describe('BasicAuthenticationProvider', () => { }); it('fails if state contains invalid credentials.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authorization = generateAuthorizationHeader('user', 'password'); const authenticationError = new Error('Forbidden'); @@ -166,7 +175,7 @@ describe('BasicAuthenticationProvider', () => { }); it('authenticates only via `authorization` header even if state is available.', async () => { - const request = requestFixture({ + const request = httpServerMock.createKibanaRequest({ headers: { authorization: generateAuthorizationHeader('user', 'password') }, }); const user = mockAuthenticatedUser(); @@ -189,14 +198,16 @@ describe('BasicAuthenticationProvider', () => { describe('`logout` method', () => { it('always redirects to the login page.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.redirected()).toBe(true); expect(deauthenticateResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); }); it('passes query string parameters to the login page.', async () => { - const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' }); + const request = httpServerMock.createKibanaRequest({ + query: { next: '/app/ml', msg: 'SESSION_EXPIRED' }, + }); const deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.redirected()).toBe(true); expect(deauthenticateResult.redirectURL).toBe( diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 0cab25a12c59ab..fc9f9832a388c8 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -6,9 +6,9 @@ import Boom from 'boom'; import sinon from 'sinon'; -import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { requestFixture } from '../../__fixtures__'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions, @@ -27,7 +27,9 @@ describe('KerberosAuthenticationProvider', () => { describe('`authenticate` method', () => { it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic some:credentials' }, + }); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', @@ -42,7 +44,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') .resolves({}); @@ -53,7 +55,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('does not handle requests if backend does not support Kerberos.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.unauthorized()); @@ -69,7 +71,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if state is present, but backend does not support Kerberos.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; mockScopedClusterClient(mockOptions.client) @@ -93,7 +95,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.unauthorized(null, 'Negotiate')); @@ -106,7 +108,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if request authentication is failed with non-401 error.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') .rejects(Boom.serverUnavailable()); @@ -120,7 +122,9 @@ describe('KerberosAuthenticationProvider', () => { it('gets an token pair in exchange to SPNEGO one and stores it in the state.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'negotiate spnego' }, + }); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') @@ -156,7 +160,9 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => { - const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'negotiate spnego' }, + }); const failureReason = Boom.unauthorized(); mockOptions.client.callAsInternalUser @@ -178,7 +184,9 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if could not retrieve user using the new access token.', async () => { - const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'negotiate spnego' }, + }); const failureReason = Boom.unauthorized(); mockScopedClusterClient( @@ -208,7 +216,7 @@ describe('KerberosAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', @@ -230,7 +238,7 @@ describe('KerberosAuthenticationProvider', () => { it('succeeds with valid session even if requiring a token refresh', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockScopedClusterClient( @@ -263,7 +271,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if token from the state is rejected because of unknown reason.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', @@ -285,7 +293,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; mockScopedClusterClient(mockOptions.client) @@ -301,7 +309,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails with `Negotiate` challenge if both access and refresh token documents are missing and backend supports Kerberos.', async () => { - const request = requestFixture({ headers: {} }); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'missing-token', refreshToken: 'missing-refresh-token' }; mockScopedClusterClient( @@ -329,7 +337,9 @@ describe('KerberosAuthenticationProvider', () => { it('succeeds if `authorization` contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-valid-token' }, + }); mockScopedClusterClient( mockOptions.client, @@ -348,7 +358,9 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if token from `authorization` header is rejected.', async () => { - const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-invalid-token' }, + }); const failureReason = { statusCode: 401 }; mockScopedClusterClient( @@ -366,7 +378,9 @@ describe('KerberosAuthenticationProvider', () => { it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-invalid-token' }, + }); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', @@ -396,7 +410,7 @@ describe('KerberosAuthenticationProvider', () => { describe('`logout` method', () => { it('returns `notHandled` if state is not presented.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); @@ -408,7 +422,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if `tokens.invalidate` fails', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); @@ -424,7 +438,7 @@ describe('KerberosAuthenticationProvider', () => { }); it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 35127aad790949..c9a0e4350d8869 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -6,9 +6,9 @@ import sinon from 'sinon'; import Boom from 'boom'; -import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { requestFixture } from '../../__fixtures__'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions, @@ -41,7 +41,7 @@ describe('OIDCAuthenticationProvider', () => { describe('`login` method', () => { it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => { - const request = requestFixture({ path: '/api/security/v1/oidc' }); + const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' }); mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ state: 'statevalue', @@ -81,7 +81,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { - const request = requestFixture({ + const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', }); @@ -116,7 +116,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if authentication response is presented but session state does not contain the state parameter.', async () => { - const request = requestFixture({ path: '/api/security/v1/oidc' }); + const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' }); const authenticationResult = await provider.login( request, @@ -135,7 +135,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if authentication response is presented but session state does not contain redirect URL.', async () => { - const request = requestFixture({ path: '/api/security/v1/oidc' }); + const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' }); const authenticationResult = await provider.login( request, @@ -154,7 +154,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if session state is not presented.', async () => { - const request = requestFixture({ + const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', }); @@ -166,7 +166,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if code is invalid.', async () => { - const request = requestFixture({ + const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', }); @@ -202,7 +202,7 @@ describe('OIDCAuthenticationProvider', () => { describe('`authenticate` method', () => { it('does not handle AJAX request that can not be authenticated.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); const authenticationResult = await provider.authenticate(request, null); @@ -210,7 +210,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { - const request = requestFixture({ path: '/s/foo/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ state: 'statevalue', @@ -245,7 +245,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if OpenID Connect authentication request preparation fails.', async () => { - const request = requestFixture({ path: '/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser @@ -264,7 +264,7 @@ describe('OIDCAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', @@ -285,7 +285,9 @@ describe('OIDCAuthenticationProvider', () => { }); it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic some:credentials' }, + }); const authenticationResult = await provider.authenticate(request, { accessToken: 'some-valid-token', @@ -298,7 +300,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if token from the state is rejected because of unknown reason.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-invalid-token', refreshToken: 'some-invalid-refresh-token', @@ -319,7 +321,7 @@ describe('OIDCAuthenticationProvider', () => { it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; mockScopedClusterClient( @@ -355,7 +357,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if token from the state is expired and refresh attempt failed too.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; mockScopedClusterClient( @@ -379,7 +381,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => { - const request = requestFixture({ path: '/s/foo/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ @@ -424,7 +426,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; mockScopedClusterClient( @@ -448,7 +450,7 @@ describe('OIDCAuthenticationProvider', () => { it('succeeds if `authorization` contains a valid token.', async () => { const user = mockAuthenticatedUser(); const authorization = 'Bearer some-valid-token'; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) .callAsCurrentUser.withArgs('shield.authenticate') @@ -465,7 +467,7 @@ describe('OIDCAuthenticationProvider', () => { it('fails if token from `authorization` header is rejected.', async () => { const authorization = 'Bearer some-invalid-token'; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const failureReason = { statusCode: 401 }; mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) @@ -481,7 +483,7 @@ describe('OIDCAuthenticationProvider', () => { it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { const user = mockAuthenticatedUser(); const authorization = 'Bearer some-invalid-token'; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const failureReason = { statusCode: 401 }; mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) @@ -507,7 +509,7 @@ describe('OIDCAuthenticationProvider', () => { describe('`logout` method', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); let deauthenticateResult = await provider.logout(request, {}); expect(deauthenticateResult.notHandled()).toBe(true); @@ -522,7 +524,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if OpenID Connect logout call fails.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; @@ -546,7 +548,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects to /logged_out if `redirect` field in OpenID Connect logout response is null.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; @@ -569,7 +571,7 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects user to the OpenID Connect Provider if RP initiated SLO is supported.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 8c7ad4700fa573..4d4fa796851d0b 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -6,9 +6,9 @@ import Boom from 'boom'; import sinon from 'sinon'; -import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { requestFixture } from '../../__fixtures__'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions, @@ -41,7 +41,7 @@ describe('SAMLAuthenticationProvider', () => { describe('`login` method', () => { it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser .withArgs('shield.samlAuthenticate') @@ -68,7 +68,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authenticationResult = await provider.login( request, @@ -87,7 +87,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if SAML Response payload is presented but state does not contain redirect URL.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authenticationResult = await provider.login( request, @@ -106,7 +106,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to the default location if state is not presented.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ access_token: 'idp-initiated-login-token', @@ -132,7 +132,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if SAML Response is rejected.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const failureReason = new Error('SAML response is stale!'); mockOptions.client.callAsInternalUser @@ -157,7 +157,7 @@ describe('SAMLAuthenticationProvider', () => { describe('IdP initiated login with existing session', () => { it('fails if new SAML Response is rejected.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const user = mockAuthenticatedUser(); mockScopedClusterClient(mockOptions.client) @@ -186,7 +186,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if token received in exchange to new SAML Response is rejected.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); // Call to `authenticate` using existing valid session. const user = mockAuthenticatedUser(); @@ -227,7 +227,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if fails to invalidate existing access/refresh tokens.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', @@ -262,7 +262,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to the home page if new SAML Response is for the same user.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', @@ -299,7 +299,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to `overwritten_session` if new SAML Response is for the another user.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', @@ -347,7 +347,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to `overwritten_session` if new SAML Response is for another realm.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', @@ -398,7 +398,7 @@ describe('SAMLAuthenticationProvider', () => { describe('`authenticate` method', () => { it('does not handle AJAX request that can not be authenticated.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); const authenticationResult = await provider.authenticate(request, null); @@ -406,7 +406,9 @@ describe('SAMLAuthenticationProvider', () => { }); it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic some:credentials' }, + }); const authenticationResult = await provider.authenticate(request, { accessToken: 'some-valid-token', @@ -419,7 +421,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the IdP.', async () => { - const request = requestFixture({ path: '/s/foo/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ id: 'some-request-id', @@ -443,7 +445,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if SAML request preparation fails.', async () => { - const request = requestFixture({ path: '/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); @@ -460,7 +462,7 @@ describe('SAMLAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', @@ -481,7 +483,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if token from the state is rejected because of unknown reason.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', @@ -504,7 +506,7 @@ describe('SAMLAuthenticationProvider', () => { it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; mockScopedClusterClient( @@ -540,7 +542,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; mockScopedClusterClient( @@ -564,7 +566,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; mockScopedClusterClient( @@ -586,7 +588,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('initiates SAML handshake for non-AJAX requests if access token document is missing.', async () => { - const request = requestFixture({ path: '/s/foo/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ @@ -623,7 +625,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('initiates SAML handshake for non-AJAX requests if refresh token is expired.', async () => { - const request = requestFixture({ path: '/s/foo/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ @@ -659,7 +661,7 @@ describe('SAMLAuthenticationProvider', () => { it('succeeds if `authorization` contains a valid token.', async () => { const user = mockAuthenticatedUser(); const authorization = 'Bearer some-valid-token'; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) .callAsCurrentUser.withArgs('shield.authenticate') @@ -676,7 +678,7 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from `authorization` header is rejected.', async () => { const authorization = 'Bearer some-invalid-token'; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const failureReason = { statusCode: 401 }; mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) @@ -692,7 +694,7 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { const user = mockAuthenticatedUser(); const authorization = 'Bearer some-invalid-token'; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const failureReason = { statusCode: 401 }; mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) @@ -718,7 +720,7 @@ describe('SAMLAuthenticationProvider', () => { describe('`logout` method', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); let deauthenticateResult = await provider.logout(request); expect(deauthenticateResult.notHandled()).toBe(true); @@ -733,7 +735,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if SAML logout call fails.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -755,7 +757,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if SAML invalidate call fails.', async () => { - const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser @@ -776,7 +778,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -799,7 +801,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to /logged_out if `redirect` field in SAML logout response is not defined.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -822,7 +824,9 @@ describe('SAMLAuthenticationProvider', () => { }); it('relies on SAML logout if query string is not empty, but does not include SAMLRequest.', async () => { - const request = requestFixture({ search: '?Whatever=something%20unrelated' }); + const request = httpServerMock.createKibanaRequest({ + query: { Whatever: 'something unrelated' }, + }); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -845,7 +849,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('relies on SAML invalidate call even if access token is presented.', async () => { - const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser .withArgs('shield.samlInvalidate') @@ -868,7 +872,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => { - const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser .withArgs('shield.samlInvalidate') @@ -888,7 +892,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { - const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser .withArgs('shield.samlInvalidate') @@ -908,7 +912,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -927,7 +931,7 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects user to the IdP if SLO is supported by IdP in case of IdP initiated logout.', async () => { - const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser .withArgs('shield.samlInvalidate') diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 324d42a982f8cf..8eb20447c7e2cd 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -7,13 +7,15 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; import sinon from 'sinon'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { requestFixture } from '../../__fixtures__'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions, mockScopedClusterClient, } from './base.mock'; + import { TokenAuthenticationProvider } from './token'; describe('TokenAuthenticationProvider', () => { @@ -26,7 +28,7 @@ describe('TokenAuthenticationProvider', () => { describe('`login` method', () => { it('succeeds with valid login attempt, creates session and authHeaders', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const user = mockAuthenticatedUser(); const credentials = { username: 'user', password: 'password' }; @@ -52,7 +54,7 @@ describe('TokenAuthenticationProvider', () => { }); it('fails if token cannot be generated during login attempt', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const credentials = { username: 'user', password: 'password' }; const authenticationError = new Error('Invalid credentials'); @@ -74,7 +76,7 @@ describe('TokenAuthenticationProvider', () => { }); it('fails if user cannot be retrieved during login attempt', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const credentials = { username: 'user', password: 'password' }; const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; @@ -107,7 +109,7 @@ describe('TokenAuthenticationProvider', () => { // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and // avoid triggering of redirect logic. const authenticationResult = await provider.authenticate( - requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }), + httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), null ); @@ -116,7 +118,7 @@ describe('TokenAuthenticationProvider', () => { it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { const authenticationResult = await provider.authenticate( - requestFixture({ path: '/s/foo/some-path # that needs to be encoded' }), + httpServerMock.createKibanaRequest({ path: '/s/foo/some-path # that needs to be encoded' }), null ); @@ -128,7 +130,7 @@ describe('TokenAuthenticationProvider', () => { it('succeeds if only `authorization` header is available and returns neither state nor authHeaders.', async () => { const authorization = 'Bearer foo'; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const user = mockAuthenticatedUser(); mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) @@ -144,7 +146,7 @@ describe('TokenAuthenticationProvider', () => { }); it('succeeds if only state is available.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const user = mockAuthenticatedUser(); const authorization = `Bearer ${tokenPair.accessToken}`; @@ -164,7 +166,7 @@ describe('TokenAuthenticationProvider', () => { it('succeeds with valid session even if requiring a token refresh', async () => { const user = mockAuthenticatedUser(); - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockScopedClusterClient( @@ -197,7 +199,9 @@ describe('TokenAuthenticationProvider', () => { }); it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic ***' } }); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic ***' }, + }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const user = mockAuthenticatedUser(); const authorization = `Bearer ${tokenPair.accessToken}`; @@ -216,7 +220,7 @@ describe('TokenAuthenticationProvider', () => { it('authenticates only via `authorization` header even if state is available.', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer foo-from-header`; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const user = mockAuthenticatedUser(); // GetUser will be called with request's `authorization` header. @@ -235,7 +239,7 @@ describe('TokenAuthenticationProvider', () => { it('fails if authentication with token from header fails with unknown error', async () => { const authorization = `Bearer foo`; - const request = requestFixture({ headers: { authorization } }); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const authenticationError = new errors.InternalServerError('something went wrong'); mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) @@ -252,7 +256,7 @@ describe('TokenAuthenticationProvider', () => { it('fails if authentication with token from state fails with unknown error.', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const authenticationError = new errors.InternalServerError('something went wrong'); mockScopedClusterClient( @@ -272,7 +276,7 @@ describe('TokenAuthenticationProvider', () => { }); it('fails if token refresh is rejected with unknown error', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockScopedClusterClient( @@ -297,7 +301,7 @@ describe('TokenAuthenticationProvider', () => { }); it('redirects non-AJAX requests to /login and clears session if token document is missing', async () => { - const request = requestFixture({ path: '/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockScopedClusterClient( @@ -327,7 +331,7 @@ describe('TokenAuthenticationProvider', () => { }); it('redirects non-AJAX requests to /login and clears session if token cannot be refreshed', async () => { - const request = requestFixture({ path: '/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockScopedClusterClient( @@ -354,7 +358,10 @@ describe('TokenAuthenticationProvider', () => { }); it('does not redirect AJAX requests if token token cannot be refreshed', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' }, path: '/some-path' }); + const request = httpServerMock.createKibanaRequest({ + headers: { 'kbn-xsrf': 'xsrf' }, + path: '/some-path', + }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockScopedClusterClient( @@ -380,7 +387,7 @@ describe('TokenAuthenticationProvider', () => { }); it('fails if new access token is rejected after successful refresh', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockScopedClusterClient( @@ -416,7 +423,7 @@ describe('TokenAuthenticationProvider', () => { describe('`logout` method', () => { it('returns `notHandled` if state is not presented.', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; let deauthenticateResult = await provider.logout(request); @@ -432,7 +439,7 @@ describe('TokenAuthenticationProvider', () => { }); it('fails if `tokens.invalidate` fails', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); @@ -448,7 +455,7 @@ describe('TokenAuthenticationProvider', () => { }); it('redirects to /login if tokens are invalidated successfully', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); @@ -463,7 +470,7 @@ describe('TokenAuthenticationProvider', () => { }); it('redirects to /login with optional search parameters if tokens are invalidated successfully', async () => { - const request = requestFixture({ search: '?yep' }); + const request = httpServerMock.createKibanaRequest({ query: { yep: 'nope' } }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); @@ -474,7 +481,7 @@ describe('TokenAuthenticationProvider', () => { sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/login?yep'); + expect(authenticationResult.redirectURL).toBe('/base-path/login?yep=nope'); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index 21d6ac6a4ba195..0f0e3ae17f494f 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -6,21 +6,17 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; -import sinon from 'sinon'; -import { ClusterClient } from '../../../../../src/core/server'; -import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; + +import { ClusterClient } from '../../../../../src/core/server'; import { Tokens } from './tokens'; describe('Tokens', () => { let tokens: Tokens; - let mockClusterClient: sinon.SinonStubbedInstance; + let mockClusterClient: jest.Mocked>; beforeEach(() => { - mockClusterClient = { - callAsInternalUser: sinon.stub(), - asScoped: sinon.stub(), - close: sinon.stub(), - }; + mockClusterClient = elasticsearchServiceMock.createClusterClient(); const tokensOptions = { client: mockClusterClient, @@ -38,10 +34,7 @@ describe('Tokens', () => { Boom.forbidden(), new errors.InternalServerError(), new errors.Forbidden(), - { - statusCode: 500, - body: { error: { reason: 'some unknown reason' } }, - }, + { statusCode: 500, body: { error: { reason: 'some unknown reason' } } }, ]; for (const error of nonExpirationErrors) { expect(Tokens.isAccessTokenExpiredError(error)).toBe(false); @@ -66,35 +59,41 @@ describe('Tokens', () => { it('throws if API call fails with unknown reason', async () => { const refreshFailureReason = Boom.serverUnavailable('Server is not available'); - mockClusterClient.callAsInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }) - .rejects(refreshFailureReason); + mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).rejects.toBe(refreshFailureReason); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: refreshToken }, + }); }); it('returns `null` if refresh token is not valid', async () => { const refreshFailureReason = Boom.badRequest(); - mockClusterClient.callAsInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }) - .rejects(refreshFailureReason); + mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).resolves.toBe(null); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: refreshToken }, + }); }); it('returns token pair if refresh API call succeeds', async () => { const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' }; - mockClusterClient.callAsInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }) - .resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken }); + mockClusterClient.callAsInternalUser.mockResolvedValue({ + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + }); await expect(tokens.refresh(refreshToken)).resolves.toEqual(tokenPair); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: refreshToken }, + }); }); }); @@ -103,24 +102,22 @@ describe('Tokens', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } }) - .rejects(failureReason); + mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + if (args && args.body && args.body.token) { + return Promise.reject(failureReason); + } - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } }) - .resolves({ invalidated_tokens: 1 }); + return Promise.resolve({ invalidated_tokens: 1 }); + }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { token: tokenPair.accessToken } } ); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } } ); @@ -130,24 +127,22 @@ describe('Tokens', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } }) - .rejects(failureReason); + mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + if (args && args.body && args.body.refresh_token) { + return Promise.reject(failureReason); + } - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } }) - .resolves({ invalidated_tokens: 1 }); + return Promise.resolve({ invalidated_tokens: 1 }); + }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { token: tokenPair.accessToken } } ); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } } ); @@ -156,20 +151,16 @@ describe('Tokens', () => { it('invalidates all provided tokens', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken') - .resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { token: tokenPair.accessToken } } ); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } } ); @@ -178,15 +169,12 @@ describe('Tokens', () => { it('invalidates only access token if only access token is provided', async () => { const tokenPair = { accessToken: 'foo' }; - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken') - .resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledOnce(mockClusterClient.callAsInternalUser); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { token: tokenPair.accessToken } } ); @@ -195,15 +183,12 @@ describe('Tokens', () => { it('invalidates only refresh token if only refresh token is provided', async () => { const tokenPair = { refreshToken: 'foo' }; - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken') - .resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledOnce(mockClusterClient.callAsInternalUser); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } } ); @@ -212,20 +197,16 @@ describe('Tokens', () => { it('does not fail if none of the tokens were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken') - .resolves({ invalidated_tokens: 0 }); + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 0 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { token: tokenPair.accessToken } } ); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } } ); @@ -234,20 +215,16 @@ describe('Tokens', () => { it('does not fail if more than one token per access or refresh token were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser - .withArgs('shield.deleteAccessToken') - .resolves({ invalidated_tokens: 5 }); + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 5 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { token: tokenPair.accessToken } } ); - sinon.assert.calledWithExactly( - mockClusterClient.callAsInternalUser, + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } } ); diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 2cb6c92902ecdc..991841b4fd399d 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -172,12 +172,6 @@ Object { }); describe('createConfig$()', () => { - const collectLogs = (contextMock: ReturnType) => { - return loggingServiceMock.collect(contextMock.logger as ReturnType< - typeof loggingServiceMock['create'] - >); - }; - it('should log a warning and set xpack.security.encryptionKey if not set', async () => { const mockRandomBytes = jest.requireMock('crypto').randomBytes; mockRandomBytes.mockReturnValue('ab'.repeat(16)); @@ -188,7 +182,7 @@ describe('createConfig$()', () => { .toPromise(); expect(config).toEqual({ encryptionKey: 'ab'.repeat(16), secureCookies: true }); - expect(collectLogs(contextMock).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml", @@ -208,7 +202,7 @@ Array [ .toPromise(); expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: false }); - expect(collectLogs(contextMock).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Session cookies will be transmitted over insecure connections. This is not recommended.", @@ -228,7 +222,7 @@ Array [ .toPromise(); expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); - expect(collectLogs(contextMock).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.", @@ -248,6 +242,6 @@ Array [ .toPromise(); expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); - expect(collectLogs(contextMock).warn).toEqual([]); + expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); }); }); diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index dd424a61f191d2..3713257fead8a6 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -4,14 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ClusterClient } from '../../../../src/core/server'; -import { coreMock } from '../../../../src/core/server/mocks'; +import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; + import { Plugin } from './plugin'; +import { ClusterClient, CoreSetup } from '../../../../src/core/server'; describe('Security Plugin', () => { let plugin: Plugin; - let mockCoreSetup: ReturnType; - let mockClusterClient: jest.Mocked; + let mockCoreSetup: MockedKeys; + let mockClusterClient: jest.Mocked>; beforeEach(() => { plugin = new Plugin( coreMock.createPluginInitializerContext({ @@ -23,18 +24,11 @@ describe('Security Plugin', () => { mockCoreSetup = coreMock.createSetup(); mockCoreSetup.http.isTlsEnabled = true; - mockCoreSetup.http.registerAuth.mockResolvedValue({ - sessionStorageFactory: { - asScoped: jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn(), clear: jest.fn() }), - }, - }); - mockClusterClient = { - callAsInternalUser: jest.fn(), - asScoped: jest.fn(), - close: jest.fn(), - } as any; - mockCoreSetup.elasticsearch.createClient.mockReturnValue(mockClusterClient); + mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockCoreSetup.elasticsearch.createClient.mockReturnValue( + (mockClusterClient as unknown) as jest.Mocked + ); }); describe('setup()', () => { From 63c3eac766a14e9c1509ce8f96060c133de1dabd Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 12 Jul 2019 10:35:35 +0200 Subject: [PATCH 10/13] Expect ElasticsearchError instead of Boom errors as 401 Cluster client errors. --- .../server/authentication/index.test.ts | 14 ++-- .../authentication/providers/kerberos.test.ts | 68 ++++++++++--------- .../authentication/providers/kerberos.ts | 16 +++-- .../server/authentication/tokens.test.ts | 11 ++- 4 files changed, 57 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 00520ceb999221..6b77a05412c25f 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -7,6 +7,7 @@ jest.mock('./authenticator'); import Boom from 'boom'; +import { errors } from 'elasticsearch'; import { first } from 'rxjs/operators'; import { @@ -23,6 +24,7 @@ import { AuthToolkit, ClusterClient, CoreSetup, + ElasticsearchErrorHelpers, KibanaRequest, LoggerFactory, ScopedClusterClient, @@ -198,19 +200,19 @@ describe('setupAuthentication()', () => { }); it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => { - const originalEsError = Boom.unauthorized('some message'); - originalEsError.output.headers['WWW-Authenticate'] = [ + const originalError = Boom.unauthorized('some message'); + originalError.output.headers['WWW-Authenticate'] = [ 'Basic realm="Access to prod", charset="UTF-8"', 'Basic', 'Negotiate', ] as any; - authenticate.mockResolvedValue(AuthenticationResult.failed(originalEsError, ['Negotiate'])); + authenticate.mockResolvedValue(AuthenticationResult.failed(originalError, ['Negotiate'])); await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit); expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); const [[error]] = mockAuthToolkit.rejected.mock.calls; - expect(error.message).toBe(originalEsError.message); + expect(error.message).toBe(originalError.message); expect((error as Boom).output.headers).toEqual({ 'WWW-Authenticate': ['Negotiate'] }); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); @@ -281,14 +283,14 @@ describe('setupAuthentication()', () => { }); it('returns `false` if `authenticate` fails with 401.', async () => { - const failureReason = Boom.unauthorized(); + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(false); }); it('fails if `authenticate` call fails with unknown reason', async () => { - const failureReason = Boom.badRequest(); + const failureReason = new errors.BadRequest(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); await expect(isAuthenticated(httpServerMock.createKibanaRequest())).rejects.toBe( diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index fc9f9832a388c8..4d9287ba9e5817 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; +import { errors } from 'elasticsearch'; import sinon from 'sinon'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; @@ -16,6 +16,7 @@ import { } from './base.mock'; import { KerberosAuthenticationProvider } from './kerberos'; +import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server/elasticsearch'; describe('KerberosAuthenticationProvider', () => { let provider: KerberosAuthenticationProvider; @@ -58,15 +59,9 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized()); + .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); - let authenticationResult = await provider.authenticate(request, null); - expect(authenticationResult.notHandled()).toBe(true); - - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized(null, 'Basic')); - authenticationResult = await provider.authenticate(request, null); + const authenticationResult = await provider.authenticate(request, null); expect(authenticationResult.notHandled()).toBe(true); }); @@ -76,19 +71,10 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized()); + .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); - let authenticationResult = await provider.authenticate(request, tokenPair); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.challenges).toBeUndefined(); - - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized(null, 'Basic')); - - authenticationResult = await provider.authenticate(request, tokenPair); + const authenticationResult = await provider.authenticate(request, tokenPair); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); expect(authenticationResult.challenges).toBeUndefined(); @@ -98,7 +84,13 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized(null, 'Negotiate')); + .rejects( + ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) + ); const authenticationResult = await provider.authenticate(request, null); @@ -111,12 +103,12 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.serverUnavailable()); + .rejects(new errors.ServiceUnavailable()); const authenticationResult = await provider.authenticate(request, null); expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 503); + expect(authenticationResult.error).toHaveProperty('status', 503); expect(authenticationResult.challenges).toBeUndefined(); }); @@ -128,7 +120,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.serverUnavailable()); + .rejects(new errors.ServiceUnavailable()); mockScopedClusterClient( mockOptions.client, @@ -164,7 +156,7 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = Boom.unauthorized(); + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockOptions.client.callAsInternalUser .withArgs('shield.getAccessToken') .rejects(failureReason); @@ -188,7 +180,7 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = Boom.unauthorized(); + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockScopedClusterClient( mockOptions.client, sinon.match({ headers: { authorization: 'Bearer some-token' } }) @@ -246,7 +238,7 @@ describe('KerberosAuthenticationProvider', () => { sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) ) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized()); + .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); mockOptions.tokens.refresh .withArgs(tokenPair.refreshToken) @@ -277,7 +269,7 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - const failureReason = Boom.internal('Token is not valid!'); + const failureReason = new errors.InternalServerError('Token is not valid!'); const scopedClusterClient = mockScopedClusterClient( mockOptions.client, sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) @@ -298,7 +290,13 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient(mockOptions.client) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized(null, 'Negotiate')); + .rejects( + ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) + ); mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); const authenticationResult = await provider.authenticate(request, tokenPair); @@ -324,7 +322,13 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient(mockOptions.client, sinon.match({ headers: {} })) .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(Boom.unauthorized(null, 'Negotiate')); + .rejects( + ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) + ); mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); @@ -362,7 +366,7 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'Bearer some-invalid-token' }, }); - const failureReason = { statusCode: 401 }; + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockScopedClusterClient( mockOptions.client, sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) @@ -386,7 +390,7 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - const failureReason = { statusCode: 401 }; + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockScopedClusterClient( mockOptions.client, sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 73192b611284a2..57a2446b8d89b0 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -5,9 +5,11 @@ */ import Boom from 'boom'; -import { get } from 'lodash'; -import { KibanaRequest } from '../../../../../../src/core/server'; -import { getErrorStatusCode } from '../../errors'; +import { + ElasticsearchError, + ElasticsearchErrorHelpers, + KibanaRequest, +} from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { BaseAuthenticationProvider } from './base'; @@ -234,7 +236,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate request via SPNEGO.'); // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. - let authenticationError: Error; + let elasticsearchError: ElasticsearchError; try { await this.getUser(request); this.logger.debug('Request was not supposed to be authenticated, ignoring result.'); @@ -242,15 +244,15 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } catch (err) { // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch // session cookie in this case. - if (getErrorStatusCode(err) !== 401) { + if (!ElasticsearchErrorHelpers.isNotAuthorizedError(err)) { return AuthenticationResult.failed(err); } - authenticationError = err; + elasticsearchError = err; } const challenges = ([] as string[]).concat( - get(authenticationError, 'output.headers[WWW-Authenticate]') || '' + elasticsearchError.output.headers['WWW-Authenticate'] ); if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) { diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index 0f0e3ae17f494f..baf3b1f03bc9cc 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; import { errors } from 'elasticsearch'; import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; -import { ClusterClient } from '../../../../../src/core/server'; +import { ClusterClient, ElasticsearchErrorHelpers } from '../../../../../src/core/server'; import { Tokens } from './tokens'; describe('Tokens', () => { @@ -30,8 +29,6 @@ describe('Tokens', () => { const nonExpirationErrors = [ {}, new Error(), - Boom.serverUnavailable(), - Boom.forbidden(), new errors.InternalServerError(), new errors.Forbidden(), { statusCode: 500, body: { error: { reason: 'some unknown reason' } } }, @@ -42,7 +39,7 @@ describe('Tokens', () => { const expirationErrors = [ { statusCode: 401 }, - Boom.unauthorized(), + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()), new errors.AuthenticationException(), { statusCode: 500, @@ -58,7 +55,7 @@ describe('Tokens', () => { const refreshToken = 'some-refresh-token'; it('throws if API call fails with unknown reason', async () => { - const refreshFailureReason = Boom.serverUnavailable('Server is not available'); + const refreshFailureReason = new errors.ServiceUnavailable('Server is not available'); mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).rejects.toBe(refreshFailureReason); @@ -70,7 +67,7 @@ describe('Tokens', () => { }); it('returns `null` if refresh token is not valid', async () => { - const refreshFailureReason = Boom.badRequest(); + const refreshFailureReason = new errors.BadRequest(); mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).resolves.toBe(null); From e09958c04e51df5038217480bf396f1ff88bd0f7 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 15 Jul 2019 17:40:55 +0200 Subject: [PATCH 11/13] Simplify session handling for `login`. --- .../server/authentication/authenticator.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index e6bd19ccaba21d..193da0b61327ed 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -232,15 +232,28 @@ export class Authenticator { const authenticationResult = await provider.login( request, attempt.value, - existingSession ? existingSession.state : null + existingSession && existingSession.state ); - this.updateSessionValue(sessionStorage, { - providerType: attempt.provider, - isSystemAPIRequest: this.options.isSystemAPIRequest(request), - authenticationResult, - existingSession, - }); + // There are two possible cases when we'd want to clear existing state: + // 1. If provider owned the state (e.g. intermediate state used for multi step login), but failed + // to login, that likely means that state is not valid anymore and we should clear it. + // 2. Also provider can specifically ask to clear state by setting it to `null` even if + // authentication attempt didn't fail (e.g. custom realm could "pin" client/request identity to + // a server-side only session established during multi step login that relied on intermediate + // client-side state). + if ( + authenticationResult.shouldClearState() || + (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) + ) { + sessionStorage.clear(); + } else if (authenticationResult.shouldUpdateState()) { + sessionStorage.set({ + state: authenticationResult.state, + provider: attempt.provider, + expires: this.ttl && Date.now() + this.ttl, + }); + } return authenticationResult; } From 07507cd3c5128f49c05901ebef0b98f1769da5bb Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 16 Jul 2019 17:39:47 +0200 Subject: [PATCH 12/13] Review#3: properly handle session updates for `login`, remove redundant hapi-auth-cookie deps from x-pack package.json, migrate to new core sessionStorage API, integrate latest Kerberos provider changes from upstream --- x-pack/package.json | 1 - .../authentication/authenticator.test.ts | 53 ++++----- .../server/authentication/authenticator.ts | 2 +- .../server/authentication/index.test.ts | 22 ++-- .../security/server/authentication/index.ts | 106 +++++++++--------- .../authentication/providers/kerberos.test.ts | 32 ++++-- .../authentication/providers/kerberos.ts | 8 +- 7 files changed, 119 insertions(+), 105 deletions(-) diff --git a/x-pack/package.json b/x-pack/package.json index fa69065044c198..bfe14819aa89b1 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -57,7 +57,6 @@ "@types/git-url-parse": "^9.0.0", "@types/glob": "^7.1.1", "@types/graphql": "^0.13.1", - "@types/hapi-auth-cookie": "^9.1.0", "@types/history": "^4.6.2", "@types/jest": "^24.0.9", "@types/joi": "^13.4.2", diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 15f1b7cf52622d..a4f503242101d0 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -133,47 +133,21 @@ describe('Authenticator', () => { expect(authenticationResult.authHeaders).toEqual({ authorization: 'Basic .....' }); }); - it('creates session whenever authentication provider returns state for system API requests', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; - - mockOptions.isSystemAPIRequest.mockReturnValue(true); - mockBasicAuthenticationProvider.login.mockResolvedValue( - AuthenticationResult.succeeded(user, { state: { authorization } }) - ); - - const systemAPIAuthenticationResult = await authenticator.login(request, { - provider: 'basic', - value: {}, - }); - expect(systemAPIAuthenticationResult.succeeded()).toBe(true); - expect(systemAPIAuthenticationResult.user).toEqual(user); - - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - expires: null, - state: { authorization }, - provider: 'basic', - }); - }); - - it('creates session whenever authentication provider returns state for non-system API requests', async () => { + it('creates session whenever authentication provider returns state', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; - mockOptions.isSystemAPIRequest.mockReturnValue(false); mockBasicAuthenticationProvider.login.mockResolvedValue( AuthenticationResult.succeeded(user, { state: { authorization } }) ); - const systemAPIAuthenticationResult = await authenticator.login(request, { + const authenticationResult = await authenticator.login(request, { provider: 'basic', value: {}, }); - expect(systemAPIAuthenticationResult.succeeded()).toBe(true); - expect(systemAPIAuthenticationResult.user).toEqual(user); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -217,6 +191,25 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + + it('clears session if provider asked to do so.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: null }) + ); + + const authenticationResult = await authenticator.login(request, { + provider: 'basic', + value: {}, + }); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); }); describe('`authenticate` method', () => { diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 193da0b61327ed..cd9b8e664d21fa 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -241,7 +241,7 @@ export class Authenticator { // 2. Also provider can specifically ask to clear state by setting it to `null` even if // authentication attempt didn't fail (e.g. custom realm could "pin" client/request identity to // a server-side only session established during multi step login that relied on intermediate - // client-side state). + // client-side state which isn't needed anymore). if ( authenticationResult.shouldClearState() || (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 6b77a05412c25f..f2d814a767bfad 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -86,7 +86,7 @@ describe('setupAuthentication()', () => { afterEach(() => jest.clearAllMocks()); - it('properly registers auth handler', async () => { + it('properly initializes session storage and registers auth handler', async () => { const config = { encryptionKey: 'ab'.repeat(16), secureCookies: true, @@ -98,14 +98,20 @@ describe('setupAuthentication()', () => { expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledTimes(1); expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledWith( - expect.any(Function), - { - encryptionKey: config.encryptionKey, - isSecure: config.secureCookies, - name: config.cookieName, - validate: expect.any(Function), - } + expect.any(Function) ); + + expect( + mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory + ).toHaveBeenCalledTimes(1); + expect( + mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory + ).toHaveBeenCalledWith({ + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + validate: expect.any(Function), + }); }); describe('authentication handler', () => { diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 6a534b53350c5f..5e4568c485d590 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -10,7 +10,6 @@ import { CoreSetup, KibanaRequest, LoggerFactory, - SessionStorageFactory, } from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../common/model'; import { ConfigType } from '../config'; @@ -60,74 +59,69 @@ export async function setupAuthentication({ .callAsCurrentUser('shield.authenticate')) as AuthenticatedUser; }; - const { sessionStorageFactory } = (await core.http.registerAuth( - async (request, t) => { - if (!authenticator) { - throw new Error('Authenticator is not initialized!'); - } + const authenticator = new Authenticator({ + clusterClient, + basePath: core.http.basePath, + config: { sessionTimeout: config.sessionTimeout, authc: config.authc }, + isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request), + loggers, + sessionStorageFactory: await core.http.createCookieSessionStorageFactory({ + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + validate: (sessionValue: ProviderSession) => + !(sessionValue.expires && sessionValue.expires < Date.now()), + }), + }); - // If security is disabled continue with no user credentials and delete the client cookie as well. - if (isSecurityFeatureDisabled()) { - return t.authenticated(); - } + authLogger.debug('Successfully initialized authenticator.'); - let authenticationResult; - try { - authenticationResult = await authenticator.authenticate(request); - } catch (err) { - authLogger.error(err); - return t.rejected(wrapError(err)); - } + core.http.registerAuth(async (request, t) => { + // If security is disabled continue with no user credentials and delete the client cookie as well. + if (isSecurityFeatureDisabled()) { + return t.authenticated(); + } - if (authenticationResult.succeeded()) { - return t.authenticated({ - state: (authenticationResult.user as unknown) as Record, - headers: authenticationResult.authHeaders, - }); - } + let authenticationResult; + try { + authenticationResult = await authenticator.authenticate(request); + } catch (err) { + authLogger.error(err); + return t.rejected(wrapError(err)); + } - if (authenticationResult.redirected()) { - // Some authentication mechanisms may require user to be redirected to another location to - // initiate or complete authentication flow. It can be Kibana own login page for basic - // authentication (username and password) or arbitrary external page managed by 3rd party - // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who - // decides what location user should be redirected to. - return t.redirected(authenticationResult.redirectURL!); - } + if (authenticationResult.succeeded()) { + return t.authenticated({ + state: (authenticationResult.user as unknown) as Record, + headers: authenticationResult.authHeaders, + }); + } - if (authenticationResult.failed()) { - authLogger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); + if (authenticationResult.redirected()) { + // Some authentication mechanisms may require user to be redirected to another location to + // initiate or complete authentication flow. It can be Kibana own login page for basic + // authentication (username and password) or arbitrary external page managed by 3rd party + // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who + // decides what location user should be redirected to. + return t.redirected(authenticationResult.redirectURL!); + } - const error = wrapError(authenticationResult.error); - if (authenticationResult.challenges) { - error.output.headers['WWW-Authenticate'] = authenticationResult.challenges as any; - } + if (authenticationResult.failed()) { + authLogger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); - return t.rejected(error); + const error = wrapError(authenticationResult.error); + if (authenticationResult.challenges) { + error.output.headers['WWW-Authenticate'] = authenticationResult.challenges as any; } - return t.rejected(Boom.unauthorized()); - }, - { - encryptionKey: config.encryptionKey, - isSecure: config.secureCookies, - name: config.cookieName, - validate: (sessionValue: ProviderSession) => - !(sessionValue.expires && sessionValue.expires < Date.now()), + return t.rejected(error); } - )) as { sessionStorageFactory: SessionStorageFactory }; - authLogger.debug('Successfully registered core authentication handler.'); - - const authenticator = new Authenticator({ - clusterClient, - basePath: core.http.basePath, - config: { sessionTimeout: config.sessionTimeout, authc: config.authc }, - isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request), - loggers, - sessionStorageFactory, + return t.rejected(Boom.unauthorized()); }); + authLogger.debug('Successfully registered core authentication handler.'); + return { login: authenticator.login.bind(authenticator), logout: authenticator.logout.bind(authenticator), diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 4d9287ba9e5817..6219725860e5fe 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -46,7 +46,12 @@ describe('KerberosAuthenticationProvider', () => { it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { const request = httpServerMock.createKibanaRequest(); - mockScopedClusterClient(mockOptions.client) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) .callAsCurrentUser.withArgs('shield.authenticate') .resolves({}); @@ -57,7 +62,12 @@ describe('KerberosAuthenticationProvider', () => { it('does not handle requests if backend does not support Kerberos.', async () => { const request = httpServerMock.createKibanaRequest(); - mockScopedClusterClient(mockOptions.client) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) .callAsCurrentUser.withArgs('shield.authenticate') .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); @@ -82,7 +92,12 @@ describe('KerberosAuthenticationProvider', () => { it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest(); - mockScopedClusterClient(mockOptions.client) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) .callAsCurrentUser.withArgs('shield.authenticate') .rejects( ElasticsearchErrorHelpers.decorateNotAuthorizedError( @@ -118,10 +133,6 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(new errors.ServiceUnavailable()); - mockScopedClusterClient( mockOptions.client, sinon.match({ headers: { authorization: 'Bearer some-token' } }) @@ -320,7 +331,12 @@ describe('KerberosAuthenticationProvider', () => { body: { error: { reason: 'token document is missing and must be present' } }, }); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: {} })) + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) .callAsCurrentUser.withArgs('shield.authenticate') .rejects( ElasticsearchErrorHelpers.decorateNotAuthorizedError( diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 57a2446b8d89b0..2f71962cbe5b51 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -238,7 +238,13 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. let elasticsearchError: ElasticsearchError; try { - await this.getUser(request); + await this.getUser(request, { + // We should send a fake SPNEGO token to Elasticsearch to make sure Kerberos realm is included + // into authentication chain and adds a `WWW-Authenticate: Negotiate` header to the error + // response. Otherwise it may not be even consulted if request can be authenticated by other + // means (e.g. when anonymous access is enabled in Elasticsearch). + authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}`, + }); this.logger.debug('Request was not supposed to be authenticated, ignoring result.'); return AuthenticationResult.notHandled(); } catch (err) { From 898094619430d93ffff00abf81ca19d37c24d15d Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 16 Jul 2019 20:26:33 +0200 Subject: [PATCH 13/13] Do not clear session on login if it does not exist. --- .../plugins/security/server/authentication/authenticator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index cd9b8e664d21fa..20a505b8556495 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -242,10 +242,10 @@ export class Authenticator { // authentication attempt didn't fail (e.g. custom realm could "pin" client/request identity to // a server-side only session established during multi step login that relied on intermediate // client-side state which isn't needed anymore). - if ( + const shouldClearSession = authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) - ) { + (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401); + if (existingSession && shouldClearSession) { sessionStorage.clear(); } else if (authenticationResult.shouldUpdateState()) { sessionStorage.set({