diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts index 3a338d1d02e06..f14b0b5fb2d41 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 9a32c69743fab..0000000000000 --- 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/common/model/index.ts b/x-pack/legacy/plugins/security/common/model/index.ts index 6557db90a6207..31757543ac3f8 100644 --- a/x-pack/legacy/plugins/security/common/model/index.ts +++ b/x-pack/legacy/plugins/security/common/model/index.ts @@ -8,6 +8,9 @@ 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'; export { BuiltinESPrivileges } from './builtin_es_privileges'; diff --git a/x-pack/legacy/plugins/security/index.d.ts b/x-pack/legacy/plugins/security/index.d.ts index bc8a09d3598bf..a0d18dd3cbb99 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 77025ad80b89b..ffd0cac439794 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'; @@ -16,10 +15,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 { @@ -34,6 +30,7 @@ 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'; export const security = (kibana) => new kibana.Plugin({ id: 'security', @@ -42,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 @@ -67,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(); }, @@ -112,15 +94,18 @@ 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(); + injectDefaultVars: (server) => { + const securityPlugin = server.newPlatform.setup.plugins.security; + if (!securityPlugin) { + throw new Error('New Platform XPack Security plugin is not available.'); + } return { - secureCookies: config.get('xpack.security.secureCookies'), - sessionTimeout: config.get('xpack.security.sessionTimeout'), - enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), + secureCookies: securityPlugin.config.secureCookies, + sessionTimeout: securityPlugin.config.sessionTimeout, + enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), }; - } + }, }, async postInit(server) { @@ -138,28 +123,29 @@ export const security = (kibana) => new kibana.Plugin({ }, async init(server) { - const plugin = this; + const securityPlugin = server.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); // 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; @@ -203,20 +189,17 @@ 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); initGetBuiltinPrivilegesApi(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 34f95fe798fe8..0000000000000 --- 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/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 abd504c86bc51..221120532318c 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 ad33b977126b4..9521cbdc58a78 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/lib/__tests__/__fixtures__/request.ts b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts index 753325215d377..c928a38d88ef3 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/__tests__/auth_redirect.js b/x-pack/legacy/plugins/security/server/lib/__tests__/auth_redirect.js deleted file mode 100644 index a96d4b5a008cc..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/auth_redirect.js +++ /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/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/legacy/plugins/security/server/lib/__tests__/validate_config.js b/x-pack/legacy/plugins/security/server/lib/__tests__/validate_config.js deleted file mode 100644 index c5a96bc8253f1..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/validate_config.js +++ /dev/null @@ -1,78 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { validateConfig } from '../validate_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(); - }); - - 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); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - 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/); - }); - - it('should throw error if xpack.security.encryptionKey is less than 32 characters', function () { - config.get.withArgs('xpack.security.encryptionKey').returns('foo'); - - const validateConfigFn = () => validateConfig(config); - expect(validateConfigFn).to.throwException(/xpack.security.encryptionKey must be at least 32 characters/); - }); - - 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); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - 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/); - }); - - 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); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - 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/); - }); - - 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); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true); - sinon.assert.notCalled(log); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/auth_redirect.js b/x-pack/legacy/plugins/security/server/lib/auth_redirect.js deleted file mode 100644 index cbcd5ecaeb479..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/auth_redirect.js +++ /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/legacy/plugins/security/server/lib/authentication/authenticator.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts deleted file mode 100644 index 622d84dbc543a..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts +++ /dev/null @@ -1,573 +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 Boom from 'boom'; -import { Legacy } from 'kibana'; - -import { serverFixture } from '../__tests__/__fixtures__/server'; -import { requestFixture } from '../__tests__/__fixtures__/request'; -import { AuthenticationResult } from './authentication_result'; -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'; - -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; - }>; - 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(); - }); - - afterEach(() => sandbox.restore()); - - 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( - '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']); - - await expect(initAuthenticator(server as any)).rejects.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); - - // Second argument will be a method we'd like to test. - authenticate = server.expose.withArgs('authenticate').firstCall.args[1]; - }); - - it('fails if request is not provided.', async () => { - await expect(authenticate(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('fails if any authentication providers fail.', async () => { - const request = requestFixture({ headers: { authorization: 'Basic ***' } }); - session.get.withArgs(request).resolves(null); - - const failureReason = new Error('Not Authorized'); - cluster.callWithRequest.withArgs(request).rejects(failureReason); - - const authenticationResult = await authenticate(request); - 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 authenticationResult = await authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - }); - - it('creates session whenever authentication provider returns state for system API requests', async () => { - const user = { username: 'user' }; - 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); - - const systemAPIAuthenticationResult = await authenticate(request); - expect(systemAPIAuthenticationResult.succeeded()).toBe(true); - expect(systemAPIAuthenticationResult.user).toEqual(user); - sinon.assert.calledOnce(session.set); - sinon.assert.calledWithExactly(session.set, request, { - state: { authorization }, - provider: 'basic', - }); - }); - - it('creates session whenever authentication provider returns state for non-system API requests', async () => { - const user = { username: 'user' }; - 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); - - 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 }, - provider: 'basic', - }); - }); - - 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' } }); - - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, - provider: 'basic', - }); - - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, - provider: 'basic', - }); - - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); - - cluster.callWithRequest - .withArgs(systemAPIRequest) - .resolves(user) - .withArgs(notSystemAPIRequest) - .resolves(user); - - 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' }, - provider: 'basic', - }); - }); - - it('does not extend session if authentication fails.', async () => { - const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } }); - const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } }); - - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, - provider: 'basic', - }); - - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, - provider: 'basic', - }); - - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); - - cluster.callWithRequest - .withArgs(systemAPIRequest) - .rejects(new Error('some error')) - .withArgs(notSystemAPIRequest) - .rejects(new Error('some 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); - sinon.assert.notCalled(session.set); - }); - - 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')}`; - 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', - }); - - server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(true); - - cluster.callWithRequest.withArgs(request).resolves(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, { - 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')}`; - 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', - }); - - server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(false); - - cluster.callWithRequest.withArgs(request).resolves(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, { - state: { authorization }, - provider: 'basic', - }); - }); - - 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' } }); - - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, - provider: 'basic', - }); - - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, - provider: 'basic', - }); - - session.clear.resolves(); - - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); - - cluster.callWithRequest - .withArgs(systemAPIRequest) - .rejects(Boom.unauthorized('token expired')) - .withArgs(notSystemAPIRequest) - .rejects(Boom.unauthorized('invalid token')); - - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.failed()).toBe(true); - - sinon.assert.calledOnce(session.clear); - sinon.assert.calledWithExactly(session.clear, systemAPIRequest); - - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.failed()).toBe(true); - - sinon.assert.calledTwice(session.clear); - sinon.assert.calledWithExactly(session.clear, notSystemAPIRequest); - }); - - 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]; - - const request = requestFixture({ headers: { xCustomHeader: 'xxx' } }); - - session.get.withArgs(request).resolves({ - state: { accessToken: 'access-xxx', refreshToken: 'refresh-xxx' }, - provider: 'token', - }); - - session.clear.resolves(); - - cluster.callWithRequest.withArgs(request).rejects({ statusCode: 401 }); - - cluster.callWithInternalUser - .withArgs('shield.getAccessToken') - .rejects(Boom.badRequest('refresh token expired')); - - const authenticationResult = await authenticate(request); - expect(authenticationResult.redirected()).toBe(true); - - sinon.assert.calledOnce(session.clear); - sinon.assert.calledWithExactly(session.clear, request); - }); - - 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' } }); - - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Basic xxx' }, - provider: 'basic', - }); - - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Basic yyy' }, - 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); - }); - - 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' }, - }); - - session.get.withArgs(systemAPIRequest).resolves({ - state: { authorization: 'Some weird authentication schema...' }, - provider: 'basic', - }); - - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { authorization: 'Some weird authentication schema...' }, - provider: 'basic', - }); - - session.clear.resolves(); - - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); - - 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); - }); - - 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' }, - }); - - session.get.withArgs(systemAPIRequest).resolves({ - state: { accessToken: 'some old token' }, - provider: 'token', - }); - - session.get.withArgs(notSystemAPIRequest).resolves({ - state: { accessToken: 'some old token' }, - provider: 'token', - }); - - session.clear.resolves(); - - server.plugins.kibana.systemApi.isSystemApiRequest - .withArgs(systemAPIRequest) - .returns(true) - .withArgs(notSystemAPIRequest) - .returns(false); - - const systemAPIAuthenticationResult = await authenticate(systemAPIRequest); - expect(systemAPIAuthenticationResult.notHandled()).toBe(true); - sinon.assert.calledOnce(session.clear); - - const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest); - expect(notSystemAPIAuthenticationResult.notHandled()).toBe(true); - sinon.assert.calledTwice(session.clear); - }); - }); - - 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'); - - await initAuthenticator(server as any); - - // Second argument will be a method we'd like to test. - deauthenticate = server.expose.withArgs('deauthenticate').firstCall.args[1]; - }); - - it('fails if request is not provided.', async () => { - await expect(deauthenticate(undefined as any)).rejects.toThrowError( - 'Request should be a valid object, was [undefined].' - ); - }); - - it('returns `notHandled` if session does not exist.', async () => { - const request = requestFixture(); - session.get.withArgs(request).resolves(null); - - const deauthenticationResult = await deauthenticate(request); - - expect(deauthenticationResult.notHandled()).toBe(true); - sinon.assert.notCalled(session.clear); - }); - - 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', - }); - - const deauthenticationResult = await deauthenticate(request); - - 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' - ); - }); - - 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', - }); - - const deauthenticationResult = await deauthenticate(request); - - sinon.assert.calledOnce(session.clear); - sinon.assert.calledWithExactly(session.clear, request); - expect(deauthenticationResult.notHandled()).toBe(true); - }); - }); - - describe('`isAuthenticated` method', () => { - let isAuthenticated: (request: ReturnType) => Promise; - beforeEach(async () => { - config.get.withArgs('xpack.security.authc.providers').returns(['basic']); - - await initAuthenticator(server as any); - - // Second argument will be a method we'd like to test. - isAuthenticated = server.expose.withArgs('isAuthenticated').firstCall.args[1]; - }); - - 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('returns `false` when `getUser` throws a 401 boom error.', async () => { - const request = requestFixture(); - server.plugins.security.getUser.withArgs(request).rejects(Boom.unauthorized()); - - await expect(isAuthenticated(request)).resolves.toBe(false); - }); - - it('throw non-boom errors.', async () => { - const request = requestFixture(); - const nonBoomError = new TypeError(); - server.plugins.security.getUser.withArgs(request).rejects(nonBoomError); - - await expect(isAuthenticated(request)).rejects.toThrowError(nonBoomError); - }); - - it('throw non-401 boom errors.', async () => { - const request = requestFixture(); - const non401Error = Boom.boomify(new TypeError()); - server.plugins.security.getUser.withArgs(request).rejects(non401Error); - - await expect(isAuthenticated(request)).rejects.toThrowError(non401Error); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts b/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts deleted file mode 100644 index 177c76b56ff63..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts +++ /dev/null @@ -1,316 +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'; -import { getErrorStatusCode } from '../errors'; -import { - AuthenticationProviderOptions, - BaseAuthenticationProvider, - BasicAuthenticationProvider, - KerberosAuthenticationProvider, - RequestWithLoginAttempt, - SAMLAuthenticationProvider, - TokenAuthenticationProvider, - OIDCAuthenticationProvider, -} 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 { - provider: string; - state: unknown; -} - -// Mapping between provider key defined in the config and authentication -// provider class that can handle specific authentication mechanism. -const providerMap = new Map< - string, - new ( - options: AuthenticationProviderOptions, - providerSpecificOptions?: AuthenticationProviderSpecificOptions - ) => BaseAuthenticationProvider ->([ - ['basic', BasicAuthenticationProvider], - ['kerberos', KerberosAuthenticationProvider], - ['saml', SAMLAuthenticationProvider], - ['token', TokenAuthenticationProvider], - ['oidc', OIDCAuthenticationProvider], -]); - -function assertRequest(request: Legacy.Request) { - if (!request || typeof request !== 'object') { - throw new Error(`Request should be a valid object, 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); - } -} - -/** - * Instantiates authentication provider based on the provider key from config. - * @param providerType Provider type key. - * @param options Options to pass to provider's constructor. - * @param providerSpecificOptions Options that are specific to {@param providerType}. - */ -function instantiateProvider( - providerType: string, - options: AuthenticationProviderOptions, - providerSpecificOptions?: AuthenticationProviderSpecificOptions -) { - const ProviderClassName = providerMap.get(providerType); - if (!ProviderClassName) { - throw new Error(`Unsupported authentication provider name: ${providerType}.`); - } - - return new ProviderClassName(options, providerSpecificOptions); -} - -/** - * Authenticator is responsible for authentication of the request using chain of - * authentication providers. The chain is essentially a prioritized list of configured - * providers (typically of various types). The order of the list determines the order in - * which the providers will be consulted. During the authentication process, Authenticator - * will try to authenticate the request via one provider at a time. Once one of the - * providers successfully authenticates the request, the authentication is considered - * to be successful and the authenticated user will be associated with the request. - * If provider cannot authenticate the request, the next in line provider in the chain - * will be used. If all providers in the chain could not authenticate the request, - * the authentication is then considered to be unsuccessful and an authentication error - * will be returned. - */ -class Authenticator { - /** - * List of configured and instantiated authentication providers. - */ - private readonly providers: Map; - - /** - * Instantiates Authenticator and bootstrap configured providers. - * @param server Server instance. - * @param session Session instance. - */ - constructor(private readonly server: Legacy.Server, private readonly session: Session) { - const config = this.server.config(); - const authProviders = config.get('xpack.security.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); - return [ - providerType, - instantiateProvider(providerType, providerOptions, providerSpecificOptions), - ] as [string, BaseAuthenticationProvider]; - }) - ); - } - - /** - * Performs request authentication using configured chain of authentication providers. - * @param request Request instance. - */ - async authenticate(request: RequestWithLoginAttempt) { - assertRequest(request); - - const isSystemApiRequest = this.server.plugins.kibana.systemApi.isSystemApiRequest(request); - const existingSession = await this.getSessionValue(request); - - let authenticationResult; - for (const [providerType, provider] of this.providerIterator(existingSession)) { - // Check if current session has been set by this provider. - const ownsSession = existingSession && existingSession.provider === providerType; - - authenticationResult = await provider.authenticate( - request, - 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()) { - return authenticationResult; - } - } - - return authenticationResult; - } - - /** - * Deauthenticates current request. - * @param request Request instance. - */ - async deauthenticate(request: RequestWithLoginAttempt) { - assertRequest(request); - - const sessionValue = await this.getSessionValue(request); - if (sessionValue) { - await this.session.clear(request); - - return this.providers.get(sessionValue.provider)!.deauthenticate(request, sessionValue.state); - } - - // Normally when there is no active session in Kibana, `deauthenticate` 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); - } - - return DeauthenticationResult.notHandled(); - } - - /** - * Returns provider iterator where providers are sorted in the order of priority (based on the session ownership). - * @param sessionValue Current session value. - */ - *providerIterator( - sessionValue: ProviderSession | null - ): 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) { - yield* this.providers; - } else { - yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; - - for (const [providerType, provider] of this.providers) { - if (providerType !== sessionValue.provider) { - yield [providerType, provider]; - } - } - } - } - - /** - * 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. - */ - private async getSessionValue(request: Legacy.Request) { - let sessionValue = await this.session.get(request); - - // 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); - 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()); - } - 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; - } - } - - return false; - }); -} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.test.ts deleted file mode 100644 index ba3f29ddd491f..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/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/legacy/plugins/security/server/lib/authentication/login_attempt.ts b/x-pack/legacy/plugins/security/server/lib/authentication/login_attempt.ts deleted file mode 100644 index 642a3cd1f2934..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/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/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts deleted file mode 100644 index a9132258c75f1..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts +++ /dev/null @@ -1,24 +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 { stub, createStubInstance } from 'sinon'; -import { Tokens } from '../tokens'; -import { AuthenticationProviderOptions } from './base'; - -export function mockAuthenticationProviderOptions( - providerOptions: Partial> = {} -) { - const client = { callWithRequest: stub(), callWithInternalUser: stub() }; - const log = stub(); - - return { - client, - log, - basePath: '/base-path', - tokens: createStubInstance(Tokens), - ...providerOptions, - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts deleted file mode 100644 index af4028185381c..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts +++ /dev/null @@ -1,64 +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 { 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; - tokens: PublicMethodsOf; -} - -/** - * Represents available provider specific options. - */ -export type AuthenticationProviderSpecificOptions = Record; - -/** - * Base class that all authentication providers should extend. - */ -export abstract class BaseAuthenticationProvider { - /** - * Instantiates AuthenticationProvider. - * @param options Provider options object. - */ - constructor(protected readonly options: Readonly) {} - - /** - * Performs request authentication. - * @param request Request instance. - * @param [state] Optional state object associated with the provider. - */ - abstract authenticate( - request: RequestWithLoginAttempt, - 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; -} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts deleted file mode 100644 index bac711b868071..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts +++ /dev/null @@ -1,226 +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. - */ - -/* eslint-disable max-classes-per-file */ - -import { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; -import { AuthenticationResult } from '../authentication_result'; -import { DeauthenticationResult } from '../deauthentication_result'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; - -/** - * Utility class that knows how to decorate request with proper Basic authentication headers. - */ -export class BasicCredentials { - /** - * Takes provided `username` and `password`, transforms them into proper `Basic ***` authorization - * header and decorates passed request with it. - * @param request Request instance. - * @param username User name. - * @param password User password. - */ - public static decorateRequest( - request: T, - username: string, - password: string - ) { - const typeOfRequest = typeof request; - if (!request || typeOfRequest !== 'object') { - throw new Error('Request should be a valid object.'); - } - - if (!username || typeof username !== 'string') { - throw new Error('Username should be a valid non-empty string.'); - } - - if (!password || typeof password !== 'string') { - throw new Error('Password should be a valid non-empty string.'); - } - - const basicCredentials = Buffer.from(`${username}:${password}`).toString('base64'); - request.headers.authorization = `Basic ${basicCredentials}`; - return request; - } -} - -/** - * The state supported by the provider. - */ -interface ProviderState { - /** - * Content of the HTTP authorization header (`Basic base-64-of-username:password`) that is based - * on user credentials used at login time and that should be provided with every request to the - * Elasticsearch on behalf of the authenticated user. - */ - authorization?: string; -} - -/** - * Provider that supports request authentication via Basic HTTP Authentication. - */ -export class BasicAuthenticationProvider extends BaseAuthenticationProvider { - /** - * 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; - } - - 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}`); - authenticationResult = AuthenticationResult.redirectTo( - `${this.options.basePath}/login?next=${nextURL}` - ); - } - - return authenticationResult; - } - - /** - * Redirects user to the login page preserving query string parameters. - * @param request Request instance. - */ - public async deauthenticate(request: Legacy.Request) { - // 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); - } - } - - /** - * Validates whether request contains `Basic ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); - - const authorization = request.headers.authorization; - if (!authorization) { - this.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}`); - return { - authenticationResult: AuthenticationResult.notHandled(), - headerNotRecognized: true, - }; - } - - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via header.'); - - return { authenticationResult: AuthenticationResult.succeeded(user) }; - } catch (err) { - this.debug(`Failed to authenticate request via header: ${err.message}`); - return { authenticationResult: 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, - { authorization }: ProviderState - ) { - this.debug('Trying to authenticate via state.'); - - if (!authorization) { - this.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.'); - - return AuthenticationResult.succeeded(user); - } 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; - - 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/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts deleted file mode 100644 index 56d6a10bd51c2..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts +++ /dev/null @@ -1,443 +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 sinon from 'sinon'; - -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; -import { mockAuthenticationProviderOptions } from './base.mock'; - -import { KerberosAuthenticationProvider } from './kerberos'; - -describe('KerberosAuthenticationProvider', () => { - let provider: KerberosAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - let callWithInternalUser: sinon.SinonStub; - let tokens: ReturnType['tokens']; - beforeEach(() => { - const providerOptions = mockAuthenticationProviderOptions(); - callWithRequest = providerOptions.client.callWithRequest; - callWithInternalUser = providerOptions.client.callWithInternalUser; - tokens = providerOptions.tokens; - - provider = new KerberosAuthenticationProvider(providerOptions); - }); - - 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 = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.notCalled(callWithRequest); - 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( - sinon.match({ - headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, - }), - 'shield.authenticate' - ) - .resolves({}); - - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.notHandled()).toBe(true); - }); - - it('does not handle requests if backend does not support Kerberos.', async () => { - const request = requestFixture(); - callWithRequest - .withArgs( - sinon.match({ - headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, - }), - 'shield.authenticate' - ) - .rejects(Boom.unauthorized()); - let authenticationResult = await provider.authenticate(request, null); - expect(authenticationResult.notHandled()).toBe(true); - - callWithRequest - .withArgs(request, 'shield.authenticate') - .rejects(Boom.unauthorized(null, 'Basic')); - authenticationResult = await provider.authenticate(request, null); - expect(authenticationResult.notHandled()).toBe(true); - }); - - it('fails if state is present, but backend does not support Kerberos.', async () => { - const request = requestFixture(); - const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - - callWithRequest.withArgs(sinon.match.any, 'shield.authenticate').rejects(Boom.unauthorized()); - 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(sinon.match.any, 'shield.authenticate') - .rejects(Boom.unauthorized(null, 'Basic')); - - authenticationResult = await provider.authenticate(request, tokenPair); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.challenges).toBeUndefined(); - }); - - it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { - const request = requestFixture(); - callWithRequest - .withArgs( - sinon.match({ - headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, - }), - 'shield.authenticate' - ) - .rejects(Boom.unauthorized(null, 'Negotiate')); - - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.challenges).toEqual(['Negotiate']); - }); - - it('fails if request authentication is failed with non-401 error.', async () => { - const request = requestFixture(); - callWithRequest - .withArgs(sinon.match.any, 'shield.authenticate') - .rejects(Boom.serverUnavailable()); - - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 503); - expect(authenticationResult.challenges).toBeUndefined(); - }); - - it('gets a token pair in exchange to SPNEGO one and stores it in the state.', async () => { - const user = { username: 'user' }; - const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer some-token' } }), - 'shield.authenticate' - ) - .resolves(user); - - callWithInternalUser - .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' }, - }); - - expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toBe(user); - expect(authenticationResult.state).toEqual({ - accessToken: 'some-token', - refreshToken: 'some-refresh-token', - }); - }); - - it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => { - const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); - - const failureReason = Boom.unauthorized(); - callWithInternalUser.withArgs('shield.getAccessToken').rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.getAccessToken', { - body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, - }); - - expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - expect(authenticationResult.challenges).toBeUndefined(); - }); - - it('fails if could not retrieve user using the new access token.', async () => { - const request = requestFixture({ headers: { authorization: 'negotiate spnego' } }); - - const failureReason = Boom.unauthorized(); - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer some-token' } }), - 'shield.authenticate' - ) - .rejects(failureReason); - - callWithInternalUser - .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' }, - }); - - expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - expect(authenticationResult.challenges).toBeUndefined(); - }); - - it('succeeds if state contains a valid token.', async () => { - const user = { username: 'user' }; - const request = requestFixture(); - const tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(request.headers.authorization).toBe('Bearer some-valid-token'); - expect(authenticationResult.succeeded()).toBe(true); - 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 request = requestFixture(); - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) - .rejects(Boom.unauthorized()); - - tokens.refresh - .withArgs(tokenPair.refreshToken) - .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer newfoo' } }), - 'shield.authenticate' - ) - .returns(user); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.calledTwice(callWithRequest); - sinon.assert.calledOnce(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'); - }); - - 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 = Boom.internal('Token is not valid!'); - callWithRequest.withArgs(request, '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'); - }); - - 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); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.challenges).toEqual(['Negotiate']); - }); - - it('fails with `Negotiate` challenge if both access and refresh token documents are missing and backend supports Kerberos.', async () => { - const request = requestFixture({ headers: {} }); - const tokenPair = { accessToken: 'missing-token', refreshToken: 'missing-refresh-token' }; - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }), - 'shield.authenticate' - ) - .rejects({ - statusCode: 500, - body: { error: { reason: 'token document is missing and must be present' } }, - }) - .withArgs( - sinon.match({ - headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, - }), - 'shield.authenticate' - ) - .rejects(Boom.unauthorized(null, 'Negotiate')); - - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.challenges).toEqual(['Negotiate']); - }); - - it('succeeds if `authorization` contains a valid token.', async () => { - const user = { username: 'user' }; - const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } }); - - callWithRequest.withArgs(request, '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.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 failureReason = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - 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 tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - const failureReason = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); - - callWithRequest - .withArgs(sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })) - .resolves(user); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - }); - - describe('`deauthenticate` method', () => { - it('returns `notHandled` if state is not presented.', async () => { - const request = requestFixture(); - - let deauthenticateResult = await provider.deauthenticate(request); - expect(deauthenticateResult.notHandled()).toBe(true); - - deauthenticateResult = await provider.deauthenticate(request, null); - expect(deauthenticateResult.notHandled()).toBe(true); - - sinon.assert.notCalled(tokens.invalidate); - }); - - it('fails if `tokens.invalidate` fails', async () => { - const request = requestFixture(); - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - - const failureReason = new Error('failed to delete token'); - tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - - const authenticationResult = await provider.deauthenticate(request, tokenPair); - - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => { - const request = requestFixture(); - const tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - tokens.invalidate.withArgs(tokenPair).resolves(); - - const authenticationResult = await provider.deauthenticate(request, tokenPair); - - sinon.assert.calledOnce(tokens.invalidate); - sinon.assert.calledWithExactly(tokens.invalidate, tokenPair); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/logged_out'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts deleted file mode 100644 index acfabffee016a..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts +++ /dev/null @@ -1,331 +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 { get } from 'lodash'; -import { Legacy } from 'kibana'; -import { getErrorStatusCode } from '../../errors'; -import { AuthenticationResult } from '../authentication_result'; -import { DeauthenticationResult } from '../deauthentication_result'; -import { Tokens, TokenPair } from '../tokens'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; - -/** - * The state supported by the provider. - */ -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) { - const authorization = request.headers.authorization; - if (!authorization) { - return ''; - } - - return authorization.split(/\s+/)[0].toLowerCase(); -} - -/** - * Provider that supports Kerberos request authentication. - */ -export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { - /** - * Performs Kerberos 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}.`); - - 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'); - return AuthenticationResult.notHandled(); - } - - let authenticationResult = AuthenticationResult.notHandled(); - if (authenticationScheme) { - // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. - authenticationResult = - authenticationScheme === 'bearer' - ? await this.authenticateWithBearerScheme(request) - : await this.authenticateWithNegotiateScheme(request); - } - - if (state && authenticationResult.notHandled()) { - authenticationResult = await this.authenticateViaState(request, state); - if ( - authenticationResult.failed() && - Tokens.isAccessTokenExpiredError(authenticationResult.error) - ) { - authenticationResult = await this.authenticateViaRefreshToken(request, state); - } - } - - // If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can - // start authentication mechanism negotiation, otherwise just return authentication result we have. - return authenticationResult.notHandled() - ? await this.authenticateViaSPNEGO(request, state) - : authenticationResult; - } - - /** - * Invalidates access token retrieved in exchange for SPNEGO token if it exists. - * @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}.`); - - if (!state) { - this.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}`); - return DeauthenticationResult.failed(err); - } - - return DeauthenticationResult.redirectTo('/logged_out'); - } - - /** - * Tries to authenticate request with `Negotiate ***` Authorization header by passing it to the Elasticsearch backend to - * 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.'); - - const [, kerberosTicket] = request.headers.authorization.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', { - body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, - }); - } catch (err) { - this.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}`; - - try { - 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: 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; - - return AuthenticationResult.failed(err); - } - } - - /** - * 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.'); - - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated using "Bearer" authentication scheme.'); - - return AuthenticationResult.succeeded(user); - } catch (err) { - this.debug( - `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` - ); - - return AuthenticationResult.failed(err); - } - } - - /** - * Tries to extract access token from 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.'); - - if (!accessToken) { - this.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'); - - this.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user); - } 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; - - return AuthenticationResult.failed(err); - } - } - - /** - * This method is only called when authentication via access token stored in the state failed because of expired - * token. So we should use refresh token, that is also stored in the state, to extend expired access token and - * authenticate user with it. - * @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.'); - - let refreshedTokenPair: TokenPair | null; - try { - refreshedTokenPair = await this.options.tokens.refresh(state.refreshToken); - } catch (err) { - return AuthenticationResult.failed(err); - } - - // 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.'); - return this.authenticateViaSPNEGO(request, state); - } - - try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, 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; - - return AuthenticationResult.failed(err); - } - } - - /** - * Tries to query Elasticsearch and see if we can rely on SPNEGO to authenticate user. - * @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.'); - - // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. - let authenticationError: Error; - try { - await this.options.client.callWithRequest( - { - headers: { - ...request.headers, - // 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')}`, - }, - }, - 'shield.authenticate' - ); - this.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 - // session cookie in this case. - if (getErrorStatusCode(err) !== 401) { - return AuthenticationResult.failed(err); - } - - authenticationError = err; - } - - const challenges = ([] as string[]).concat( - get(authenticationError, 'output.headers[WWW-Authenticate]') || '' - ); - - if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) { - this.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}].`); - - // 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. - // In this case we should reply with the `401` error and allow Authenticator to clear the cookie. - // Otherwise give a chance to the next authentication provider to authenticate request. - return state - ? 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/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts deleted file mode 100644 index 7029ddcc17817..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts +++ /dev/null @@ -1,875 +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 sinon from 'sinon'; - -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; -import { mockAuthenticationProviderOptions } from './base.mock'; - -import { SAMLAuthenticationProvider } from './saml'; - -describe('SAMLAuthenticationProvider', () => { - let provider: SAMLAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - let callWithInternalUser: sinon.SinonStub; - let tokens: ReturnType['tokens']; - 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' }); - }); - - it('throws if `realm` option is not specified', () => { - const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' }); - - expect(() => new SAMLAuthenticationProvider(providerOptions)).toThrowError( - 'Realm name must be specified' - ); - expect(() => new SAMLAuthenticationProvider(providerOptions, {})).toThrowError( - 'Realm name must be specified' - ); - expect(() => new SAMLAuthenticationProvider(providerOptions, { realm: '' })).toThrowError( - 'Realm name must be specified' - ); - }); - - 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 authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - sinon.assert.notCalled(callWithRequest); - 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 loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - sinon.assert.notCalled(callWithRequest); - expect(authenticationResult.notHandled()).toBe(true); - }); - - it('redirects non-AJAX request that can not be authenticated to the IdP.', async () => { - const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); - - callWithInternalUser.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(callWithInternalUser, 'shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ - requestId: 'some-request-id', - nextURL: `/s/foo/some-path`, - }); - }); - - it('fails if SAML request preparation fails.', async () => { - const request = requestFixture({ path: '/some-path' }); - - const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); - - const authenticationResult = await provider.authenticate(request, null); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - 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' } }); - - callWithInternalUser - .withArgs('shield.samlAuthenticate') - .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); - - const authenticationResult = await provider.authenticate(request, { - requestId: 'some-request-id', - nextURL: '/test-base-path/some-path', - }); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: ['some-request-id'], content: 'saml-response-xml' }, - }); - - 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 SAML Response payload is presented but state does not contain SAML Request token.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - - const authenticationResult = await provider.authenticate(request, { - nextURL: '/test-base-path/some-path', - } as any); - - sinon.assert.notCalled(callWithInternalUser); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest( - 'SAML response state does not have corresponding request id or redirect URL.' - ) - ); - }); - - 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 authenticationResult = await provider.authenticate(request, { - requestId: 'some-request-id', - } as any); - - sinon.assert.notCalled(callWithInternalUser); - - 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 to the default location if state is not presented.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - - callWithInternalUser.withArgs('shield.samlAuthenticate').resolves({ - access_token: 'idp-initiated-login-token', - refresh_token: 'idp-initiated-login-refresh-token', - }); - - const authenticationResult = await provider.authenticate(request); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/'); - expect(authenticationResult.state).toEqual({ - accessToken: 'idp-initiated-login-token', - refreshToken: 'idp-initiated-login-refresh-token', - }); - }); - - it('fails if SAML Response is rejected.', async () => { - const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } }); - - const failureReason = new Error('SAML response is stale!'); - callWithInternalUser.withArgs('shield.samlAuthenticate').rejects(failureReason); - - const authenticationResult = await provider.authenticate(request, { - requestId: 'some-request-id', - nextURL: '/test-base-path/some-path', - }); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', { - body: { ids: ['some-request-id'], content: 'saml-response-xml' }, - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('succeeds if state contains a valid token.', async () => { - const user = { username: 'user' }; - const request = requestFixture(); - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - expect(request.headers.authorization).toBe('Bearer some-valid-token'); - expect(authenticationResult.succeeded()).toBe(true); - 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 failureReason = { statusCode: 500, message: 'Token is not valid!' }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-invalid-token', - refreshToken: 'some-invalid-refresh-token', - }); - - 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 request = requestFixture(); - const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) - .rejects({ statusCode: 401 }); - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer new-access-token' } }), - 'shield.authenticate' - ) - .resolves(user); - - 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(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toBe(user); - expect(authenticationResult.state).toEqual({ - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - }); - }); - - it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { - const request = requestFixture(); - const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) - .rejects({ statusCode: 401 }); - - const refreshFailureReason = { - statusCode: 500, - message: 'Something is wrong with refresh token.', - }; - tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(refreshFailureReason); - }); - - it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { - 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' - ) - .rejects({ statusCode: 401 }); - - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('Both access and refresh tokens are expired.') - ); - }); - - 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 tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; - - callWithInternalUser.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' - ) - .rejects({ - statusCode: 500, - body: { error: { reason: 'token document is missing and must be present' } }, - }); - - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ - requestId: 'some-request-id', - nextURL: `/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 tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; - - callWithInternalUser.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' - ) - .rejects({ statusCode: 401 }); - - tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ - requestId: 'some-request-id', - nextURL: `/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' } }); - - callWithRequest.withArgs(request, '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.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 failureReason = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - 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 = { statusCode: 401 }; - callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); - - callWithRequest - .withArgs(sinon.match({ headers: { authorization: 'Bearer some-valid-token' } })) - .resolves(user); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - 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', () => { - it('returns `notHandled` if state is not presented or does not include access token.', async () => { - const request = requestFixture(); - - let deauthenticateResult = await provider.deauthenticate(request); - expect(deauthenticateResult.notHandled()).toBe(true); - - deauthenticateResult = await provider.deauthenticate(request, {} as any); - expect(deauthenticateResult.notHandled()).toBe(true); - - deauthenticateResult = await provider.deauthenticate(request, { somethingElse: 'x' } as any); - expect(deauthenticateResult.notHandled()).toBe(true); - - sinon.assert.notCalled(callWithInternalUser); - }); - - it('fails if SAML logout call fails.', async () => { - const request = requestFixture(); - const accessToken = 'x-saml-token'; - const refreshToken = 'x-saml-refresh-token'; - - const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.samlLogout').rejects(failureReason); - - const authenticationResult = await provider.deauthenticate(request, { - accessToken, - refreshToken, - }); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { - body: { token: accessToken, refresh_token: refreshToken }, - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('fails if SAML invalidate call fails.', async () => { - const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' }); - - const failureReason = new Error('Realm is misconfigured!'); - callWithInternalUser.withArgs('shield.samlInvalidate').rejects(failureReason); - - const authenticationResult = await provider.deauthenticate(request); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - realm: 'test-realm', - }, - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => { - const request = requestFixture(); - const accessToken = 'x-saml-token'; - const refreshToken = 'x-saml-refresh-token'; - - callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null }); - - const authenticationResult = await provider.deauthenticate(request, { - accessToken, - refreshToken, - }); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { - body: { token: accessToken, refresh_token: refreshToken }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/logged_out'); - }); - - it('redirects to /logged_out if `redirect` field in SAML logout response is not defined.', async () => { - const request = requestFixture(); - const accessToken = 'x-saml-token'; - const refreshToken = 'x-saml-refresh-token'; - - callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: undefined }); - - const authenticationResult = await provider.deauthenticate(request, { - accessToken, - refreshToken, - }); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { - body: { token: accessToken, refresh_token: refreshToken }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/logged_out'); - }); - - 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 accessToken = 'x-saml-token'; - const refreshToken = 'x-saml-refresh-token'; - - callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null }); - - const authenticationResult = await provider.deauthenticate(request, { - accessToken, - refreshToken, - }); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', { - body: { token: accessToken, refresh_token: refreshToken }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/logged_out'); - }); - - 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 }); - - const authenticationResult = await provider.deauthenticate(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', - }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/logged_out'); - }); - - 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 }); - - const authenticationResult = await provider.deauthenticate(request); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - realm: 'test-realm', - }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/logged_out'); - }); - - 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 }); - - const authenticationResult = await provider.deauthenticate(request); - - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - realm: 'test-realm', - }, - }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/logged_out'); - }); - - it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { - const request = requestFixture(); - const accessToken = 'x-saml-token'; - const refreshToken = 'x-saml-refresh-token'; - - callWithInternalUser - .withArgs('shield.samlLogout') - .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); - - const authenticationResult = await provider.deauthenticate(request, { - accessToken, - refreshToken, - }); - - sinon.assert.calledOnce(callWithInternalUser); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); - }); - - 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 - .withArgs('shield.samlInvalidate') - .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); - - const authenticationResult = await provider.deauthenticate(request, { - accessToken: 'x-saml-token', - refreshToken: 'x-saml-refresh-token', - }); - - sinon.assert.calledOnce(callWithInternalUser); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts deleted file mode 100644 index 73e757b71d51d..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts +++ /dev/null @@ -1,278 +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 { Legacy } from 'kibana'; -import { canRedirectRequest } from '../../can_redirect_request'; -import { AuthenticationResult } from '../authentication_result'; -import { DeauthenticationResult } from '../deauthentication_result'; -import { Tokens, TokenPair } from '../tokens'; -import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; - -/** - * The state supported by the provider. - */ -type ProviderState = TokenPair; - -/** - * Provider that supports token-based request authentication. - */ -export class TokenAuthenticationProvider extends BaseAuthenticationProvider { - /** - * Performs token-based 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}.`); - - // first try from login payload - let authenticationResult = await this.authenticateViaLoginAttempt(request); - - // 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; - } - - // if we still can't attempt auth, try authenticating via state (session token) - if (authenticationResult.notHandled() && state) { - authenticationResult = await this.authenticateViaState(request, state); - if ( - authenticationResult.failed() && - Tokens.isAccessTokenExpiredError(authenticationResult.error) - ) { - authenticationResult = await this.authenticateViaRefreshToken(request, state); - } - } - - // finally, if authentication still can not be handled for this - // request/state combination, redirect to the login page if appropriate - if (authenticationResult.notHandled() && canRedirectRequest(request)) { - authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request)); - } - - return authenticationResult; - } - - /** - * Redirects user to the login page preserving query string parameters. - * @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}.`); - - if (!state) { - this.debug('There are no access and refresh tokens to invalidate.'); - return DeauthenticationResult.notHandled(); - } - - this.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}`); - return DeauthenticationResult.failed(err); - } - - const queryString = request.url.search || `?msg=LOGGED_OUT`; - return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`); - } - - /** - * Validates whether request contains `Bearer ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateViaHeader(request: RequestWithLoginAttempt) { - this.debug('Trying to authenticate via header.'); - - const authorization = request.headers.authorization; - if (!authorization) { - this.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}`); - return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true }; - } - - try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.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}`); - 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.'); - - try { - request.headers.authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via state.'); - - return AuthenticationResult.succeeded(user); - } 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; - - return AuthenticationResult.failed(err); - } - } - - /** - * This method is only called when authentication via access token stored in the state failed because of expired - * token. So we should use refresh token, that is also stored in the state, to extend expired access token and - * authenticate user with it. - * @param request Request instance. - * @param state State value previously stored by the provider. - */ - private async authenticateViaRefreshToken( - request: RequestWithLoginAttempt, - { refreshToken }: ProviderState - ) { - this.debug('Trying to refresh access token.'); - - let refreshedTokenPair: TokenPair | null; - try { - refreshedTokenPair = await this.options.tokens.refresh(refreshToken); - } catch (err) { - return AuthenticationResult.failed(err); - } - - // If refresh token is no longer valid, then we should clear session and redirect user to the - // 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.'); - - // Set state to `null` to let `Authenticator` know that we want to clear current session. - return AuthenticationResult.redirectTo(this.getLoginPageURL(request), null); - } - - return AuthenticationResult.failed( - Boom.badRequest('Both access and refresh tokens are expired.') - ); - } - - try { - request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`; - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); - - this.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, 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; - - return AuthenticationResult.failed(err); - } - } - - /** - * 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); - } -} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/session.test.ts deleted file mode 100644 index fd42cbcce7c9d..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/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/legacy/plugins/security/server/lib/authentication/session.ts b/x-pack/legacy/plugins/security/server/lib/authentication/session.ts deleted file mode 100644 index 89b256ad2f68c..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/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/legacy/plugins/security/server/lib/authentication/tokens.test.ts b/x-pack/legacy/plugins/security/server/lib/authentication/tokens.test.ts deleted file mode 100644 index 9ddb1a80f4956..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.test.ts +++ /dev/null @@ -1,211 +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 { errors } from 'elasticsearch'; -import sinon from 'sinon'; - -import { Tokens } from './tokens'; - -describe('Tokens', () => { - let tokens: Tokens; - let callWithInternalUser: sinon.SinonStub; - beforeEach(() => { - const client = { callWithRequest: sinon.stub(), callWithInternalUser: sinon.stub() }; - const tokensOptions = { client, log: sinon.stub() }; - callWithInternalUser = tokensOptions.client.callWithInternalUser as sinon.SinonStub; - - tokens = new Tokens(tokensOptions); - }); - - it('isAccessTokenExpiredError() returns `true` only if token expired or its document is missing', () => { - for (const error of [ - {}, - new Error(), - Boom.serverUnavailable(), - Boom.forbidden(), - new errors.InternalServerError(), - new errors.Forbidden(), - { - statusCode: 500, - body: { error: { reason: 'some unknown reason' } }, - }, - ]) { - expect(Tokens.isAccessTokenExpiredError(error)).toBe(false); - } - - for (const error of [ - { statusCode: 401 }, - Boom.unauthorized(), - new errors.AuthenticationException(), - { - statusCode: 500, - body: { error: { reason: 'token document is missing and must be present' } }, - }, - ]) { - expect(Tokens.isAccessTokenExpiredError(error)).toBe(true); - } - }); - - describe('refresh()', () => { - const refreshToken = 'some-refresh-token'; - - it('throws if API call fails with unknown reason', async () => { - const refreshFailureReason = Boom.serverUnavailable('Server is not available'); - callWithInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }) - .rejects(refreshFailureReason); - - await expect(tokens.refresh(refreshToken)).rejects.toBe(refreshFailureReason); - }); - - it('returns `null` if refresh token is not valid', async () => { - const refreshFailureReason = Boom.badRequest(); - callWithInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }) - .rejects(refreshFailureReason); - - await expect(tokens.refresh(refreshToken)).resolves.toBe(null); - }); - - it('returns token pair if refresh API call succeeds', async () => { - const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' }; - callWithInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }) - .resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken }); - - await expect(tokens.refresh(refreshToken)).resolves.toEqual(tokenPair); - }); - }); - - describe('invalidate()', () => { - it('throws if call to delete access token responds with an error', async () => { - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - - const failureReason = new Error('failed to delete token'); - callWithInternalUser - .withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } }) - .rejects(failureReason); - - callWithInternalUser - .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 }, - }); - }); - - 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 - .withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } }) - .rejects(failureReason); - - callWithInternalUser - .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 }, - }); - }); - - it('invalidates all provided tokens', async () => { - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - - callWithInternalUser.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 }, - }); - }); - - it('invalidates only access token if only access token is provided', async () => { - const tokenPair = { accessToken: 'foo' }; - - callWithInternalUser.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 }, - }); - }); - - it('invalidates only refresh token if only refresh token is provided', async () => { - const tokenPair = { refreshToken: 'foo' }; - - callWithInternalUser.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 }, - }); - }); - - 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 }); - - 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 }, - }); - }); - - 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 }); - - 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 }, - }); - }); - }); -}); 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 d0b3321444410..0000000000000 --- 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/lib/validate_config.js b/x-pack/legacy/plugins/security/server/lib/validate_config.js deleted file mode 100644 index 49c9ba94ffd57..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/validate_config.js +++ /dev/null @@ -1,30 +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. - */ - -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); - } -} 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 ecba6c3f97e6a..8568321ba1941 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 d0594e32ba48c..3540d9b7a883b 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 c04a4f19420a7..681d2220930ef 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/__tests__/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js index 49bbdee39b917..96b47d1407bf1 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 05c5cad41e2c3..69ebc526fd898 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 e24b31039b9cb..66d099d1c5f73 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,10 +6,11 @@ import Boom from 'boom'; import Joi from 'joi'; -import { wrapError } from '../../../lib/errors'; -import { canRedirectRequest } from '../../../lib/can_redirect_request'; +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', @@ -30,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); @@ -59,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); } @@ -94,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. ' + @@ -120,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/indices.js b/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js index 2625899b09f49..7265b83783fdd 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 0e2719e87707d..09ade22d61456 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,11 +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 { wrapError } from '../../../lib/errors'; import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { BasicCredentials } from '../../../../server/lib/authentication/providers/basic'; +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); @@ -105,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 7a0a1da9ed0d3..51867631b57be 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 95c0c56ed6ad5..f7e7f2933efcc 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 0f7bf6be64156..9096a19a24d06 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 53e83ce46f442..71d6000329816 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 027dc9d2390b4..9def7da5e7e4f 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 a103a9fc6ee66..e1955f582a731 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", @@ -181,6 +180,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", @@ -241,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 0000000000000..3a93efc57b5f6 --- /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/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/server/lib/authentication/index.ts b/x-pack/plugins/security/common/model/index.ts similarity index 63% rename from x-pack/legacy/plugins/security/server/lib/authentication/index.ts rename to x-pack/plugins/security/common/model/index.ts index 1a70fdf879da5..00b17548c47ac 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AuthenticationResult } from './authentication_result'; -export { DeauthenticationResult } from './deauthentication_result'; +export { User, EditUser, getUserDisplayName } from './user'; +export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; 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 0000000000000..7ac9d654eb07e --- /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/authentication/authentication_result.test.ts b/x-pack/plugins/security/server/authentication/authentication_result.test.ts similarity index 67% 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 index b226ddd7d8b9a..a6db526178566 100644 --- a/x-pack/legacy/plugins/security/server/lib/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/legacy/plugins/security/server/lib/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts similarity index 89% rename from x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts rename to x-pack/plugins/security/server/authentication/authentication_result.ts index be443462688be..27e3f51191c1b 100644 --- a/x-pack/legacy/plugins/security/server/lib/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. */ +import { AuthHeaders } from '../../../../../src/core/server'; +import { AuthenticatedUser } from '../../common/model'; +import { getErrorStatusCode } from '../errors'; + /** * Represents status that `AuthenticationResult` can be in. */ -import { AuthenticatedUser } from '../../../common/model'; -import { getErrorStatusCode } from '../errors'; - 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 new file mode 100644 index 0000000000000..a4f503242101d --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -0,0 +1,653 @@ +/* + * 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('./providers/basic', () => ({ BasicAuthenticationProvider: jest.fn() })); + +import Boom from 'boom'; +import { SessionStorage } from '../../../../../src/core/server'; + +import { + loggingServiceMock, + httpServiceMock, + httpServerMock, + elasticsearchServiceMock, + sessionStorageMock, +} from '../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { AuthenticationResult } from './authentication_result'; +import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; +import { DeauthenticationResult } from './deauthentication_result'; +import { BasicAuthenticationProvider } from './providers'; + +function getMockOptions(config: Partial = {}) { + return { + clusterClient: elasticsearchServiceMock.createClusterClient(), + basePath: httpServiceMock.createSetupContract().basePath, + loggers: loggingServiceMock.create(), + isSystemAPIRequest: jest.fn(), + config: { sessionTimeout: null, authc: { providers: [], oidc: {}, saml: {} }, ...config }, + sessionStorageFactory: sessionStorageMock.createFactory(), + }; +} + +describe('Authenticator', () => { + let mockBasicAuthenticationProvider: jest.Mocked>; + beforeEach(() => { + mockBasicAuthenticationProvider = { + login: jest.fn(), + authenticate: jest.fn(), + logout: jest.fn(), + }; + + jest + .requireMock('./providers/basic') + .BasicAuthenticationProvider.mockImplementation(() => mockBasicAuthenticationProvider); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('initialization', () => { + 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.', () => { + const mockOptions = getMockOptions({ + authc: { providers: ['super-basic'], oidc: {}, saml: {} }, + }); + + expect(() => new Authenticator(mockOptions)).toThrowError( + 'Unsupported authentication provider name: super-basic.' + ); + }); + }); + + describe('`login` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockSessionStorage = sessionStorageMock.create(); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('fails if request is not provided.', async () => { + await expect(authenticator.login(undefined as any, undefined as any)).rejects.toThrowError( + 'Request should be a valid "KibanaRequest" instance, was [undefined].' + ); + }); + + it('fails if login attempt is not provided.', async () => { + 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(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 = httpServerMock.createKibanaRequest(); + const failureReason = new Error('Not Authorized'); + + 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 = httpServerMock.createKibanaRequest(); + + const user = mockAuthenticatedUser(); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) + ); + + 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', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); + + const authenticationResult = await authenticator.login(request, { + provider: 'basic', + value: {}, + }); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state: { authorization }, + provider: 'basic', + }); + }); + + it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { + const request = httpServerMock.createKibanaRequest(); + const authenticationResult = await authenticator.login(request, { + provider: 'token', + value: {}, + }); + expect(authenticationResult.notHandled()).toBe(true); + }); + + 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 = httpServerMock.createKibanaRequest(); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + + 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('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', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockSessionStorage = sessionStorageMock.create(); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + 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].' + ); + }); + + it('fails if an authentication provider fails.', async () => { + const request = httpServerMock.createKibanaRequest(); + const failureReason = new Error('Not Authorized'); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('returns user that authentication provider returns.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic ***' }, + }); + + const user = mockAuthenticatedUser(); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) + ); + + const authenticationResult = await authenticator.authenticate(request); + 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 = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`; + + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); + + const systemAPIAuthenticationResult = await authenticator.authenticate(request); + 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 () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + 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); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state: { authorization }, + provider: 'basic', + }); + }); + + it('does not extend session for system API calls.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(true); + 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); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('extends session for non-system API calls.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + + 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); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: null, + state, + provider: 'basic', + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('properly extends session timeout if it is defined.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); + + // Create new authenticator with non-null `sessionTimeout`. + mockOptions = getMockOptions({ + sessionTimeout: 3600 * 24, + authc: { providers: ['basic'], oidc: {}, saml: {} }, + }); + + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + + 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: currentDate + 3600 * 24, + state, + provider: 'basic', + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('does not touch session for system API calls if authentication fails with non-401 reason.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(new Error('some error')) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + 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 = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(new Error('some error')) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + 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 = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: newState }) + ); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + state: existingState, + provider: 'basic', + }); + + 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(); + }); + + 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 = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: newState }) + ); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + state: existingState, + provider: 'basic', + }); + + 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(); + }); + + it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(Boom.unauthorized()) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + + 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 = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(Boom.unauthorized()) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.failed()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + + it('clears session if provider requested it via setting state to `null`.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.redirectTo('some-url', null) + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.redirected()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + + 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 = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(true); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.notHandled() + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.notHandled()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + 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 = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(false); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.notHandled() + ); + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.notHandled()).toBe(true); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('clears session for system API request if it belongs to not configured provider.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + + 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('clears session for non-system API request if it belongs to not configured provider.', async () => { + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + + mockOptions.isSystemAPIRequest.mockReturnValue(false); + 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(); + }); + }); + + describe('`logout` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockSessionStorage = sessionStorageMock.create(); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + 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 `notHandled` if session does not exist.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(null); + + const deauthenticationResult = await authenticator.logout(request); + + expect(deauthenticationResult.notHandled()).toBe(true); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('clears session and returns whatever authentication provider returns.', async () => { + const request = httpServerMock.createKibanaRequest(); + 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); + + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(deauthenticationResult.redirected()).toBe(true); + expect(deauthenticationResult.redirectURL).toBe('some-url'); + }); + + it('only clears session if it belongs to not configured provider.', async () => { + const request = httpServerMock.createKibanaRequest(); + const state = { authorization: 'Bearer xxx' }; + mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + + const deauthenticationResult = await authenticator.logout(request); + + 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 new file mode 100644 index 0000000000000..20a505b855649 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -0,0 +1,413 @@ +/* + * 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 { + 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, + SAMLAuthenticationProvider, + TokenAuthenticationProvider, + OIDCAuthenticationProvider, + isSAMLRequestQuery, +} from './providers'; +import { AuthenticationResult } from './authentication_result'; +import { DeauthenticationResult } from './deauthentication_result'; +import { Tokens } from './tokens'; + +/** + * 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< + string, + new ( + options: AuthenticationProviderOptions, + providerSpecificOptions?: AuthenticationProviderSpecificOptions + ) => BaseAuthenticationProvider +>([ + ['basic', BasicAuthenticationProvider], + ['kerberos', KerberosAuthenticationProvider], + ['saml', SAMLAuthenticationProvider], + ['token', TokenAuthenticationProvider], + ['oidc', OIDCAuthenticationProvider], +]); + +function assertRequest(request: KibanaRequest) { + if (!(request instanceof KibanaRequest)) { + throw new Error(`Request should be a valid "KibanaRequest" instance, was [${typeof request}].`); + } +} + +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.'); + } +} + +/** + * Instantiates authentication provider based on the provider key from config. + * @param providerType Provider type key. + * @param options Options to pass to provider's constructor. + * @param providerSpecificOptions Options that are specific to {@param providerType}. + */ +function instantiateProvider( + providerType: string, + options: AuthenticationProviderOptions, + providerSpecificOptions?: AuthenticationProviderSpecificOptions +) { + const ProviderClassName = providerMap.get(providerType); + if (!ProviderClassName) { + throw new Error(`Unsupported authentication provider name: ${providerType}.`); + } + + return new ProviderClassName(options, providerSpecificOptions); +} + +/** + * Authenticator is responsible for authentication of the request using chain of + * authentication providers. The chain is essentially a prioritized list of configured + * providers (typically of various types). The order of the list determines the order in + * which the providers will be consulted. During the authentication process, Authenticator + * will try to authenticate the request via one provider at a time. Once one of the + * providers successfully authenticates the request, the authentication is considered + * to be successful and the authenticated user will be associated with the request. + * If provider cannot authenticate the request, the next in line provider in the chain + * will be used. If all providers in the chain could not authenticate the request, + * the authentication is then considered to be unsuccessful and an authentication error + * will be returned. + */ +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 options Authenticator options. + */ + 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.' + ); + } + + this.providers = new Map( + authProviders.map(providerType => { + const providerSpecificOptions = this.options.config.authc.hasOwnProperty(providerType) + ? (this.options.config.authc as Record)[providerType] + : undefined; + + return [ + providerType, + 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 + ); + + // 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 which isn't needed anymore). + const shouldClearSession = + authenticationResult.shouldClearState() || + (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401); + if (existingSession && shouldClearSession) { + sessionStorage.clear(); + } else if (authenticationResult.shouldUpdateState()) { + sessionStorage.set({ + state: authenticationResult.state, + provider: attempt.provider, + expires: this.ttl && Date.now() + this.ttl, + }); + } + + return authenticationResult; + } + + /** + * Performs request authentication using configured chain of authentication providers. + * @param request Request instance. + */ + async authenticate(request: KibanaRequest) { + assertRequest(request); + + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const existingSession = await this.getSessionValue(sessionStorage); + + 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; + + authenticationResult = await provider.authenticate( + request, + ownsSession ? existingSession!.state : null + ); + + this.updateSessionValue(sessionStorage, { + providerType, + isSystemAPIRequest: this.options.isSystemAPIRequest(request), + authenticationResult, + existingSession: ownsSession ? existingSession : null, + }); + + if ( + authenticationResult.failed() || + authenticationResult.succeeded() || + authenticationResult.redirected() + ) { + return authenticationResult; + } + } + + return authenticationResult; + } + + /** + * Deauthenticates current request. + * @param request Request instance. + */ + async logout(request: KibanaRequest) { + assertRequest(request); + + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const sessionValue = await this.getSessionValue(sessionStorage); + if (sessionValue) { + sessionStorage.clear(); + + return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state); + } + + // 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 (isSAMLRequestQuery(request.query) && this.providers.has('saml')) { + return this.providers.get('saml')!.logout(request); + } + + return DeauthenticationResult.notHandled(); + } + + /** + * Returns provider iterator where providers are sorted in the order of priority (based on the session ownership). + * @param sessionValue Current session value. + */ + private *providerIterator( + sessionValue: ProviderSession | null + ): 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) { + yield* this.providers; + } else { + yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; + + for (const [providerType, provider] of this.providers) { + if (providerType !== sessionValue.provider) { + yield [providerType, provider]; + } + } + } + } + + /** + * 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 sessionStorage Session storage instance. + */ + 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)) { + sessionStorage.clear(); + sessionValue = null; + } + + return sessionValue; + } + + private updateSessionValue( + sessionStorage: SessionStorage, + { + providerType, + authenticationResult, + existingSession, + isSystemAPIRequest, + }: { + providerType: string; + authenticationResult: AuthenticationResult; + existingSession: ProviderSession | null; + isSystemAPIRequest: boolean; + } + ) { + if (!existingSession && !authenticationResult.shouldUpdateState()) { + return; + } + + // 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/legacy/plugins/security/server/lib/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts similarity index 61% 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 index b132b39c7ae7e..1c9b936692f9e 100644 --- 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 @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { requestFixture } from './__tests__/__fixtures__/request'; +import { httpServerMock } from '../../../../../src/core/server/http/http_server.mocks'; + 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); + expect(canRedirectRequest(httpServerMock.createKibanaRequest())).toBe(true); }); it('returns false if request has a kbn-version header', () => { - const request = requestFixture(); - request.raw.req.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(); - request.raw.req.headers['kbn-xsrf'] = 'something'; + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'something' } }); expect(canRedirectRequest(request)).toBe(false); }); 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 65% 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 index c87d31f8ff0c2..7e2b2e5eaf9f5 100644 --- a/x-pack/legacy/plugins/security/server/lib/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/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/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts new file mode 100644 index 0000000000000..f2d814a767bfa --- /dev/null +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -0,0 +1,307 @@ +/* + * 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 { errors } from 'elasticsearch'; +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, + ElasticsearchErrorHelpers, + KibanaRequest, + LoggerFactory, + ScopedClusterClient, +} from '../../../../../src/core/server'; +import { AuthenticatedUser } from '../../common/model'; +import { ConfigType, createConfig$ } from '../config'; +import { getErrorStatusCode } from '../errors'; +import { LegacyAPI } from '../plugin'; +import { AuthenticationResult } from './authentication_result'; +import { setupAuthentication } 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: { + config: ConfigType; + loggers: LoggerFactory; + getLegacyAPI(): LegacyAPI; + core: MockedKeys; + clusterClient: jest.Mocked>; + }; + let mockXpackInfo: jest.Mocked; + let mockScopedClusterClient: jest.Mocked>; + beforeEach(async () => { + 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: coreMock.createSetup(), + config: await mockConfig$.pipe(first()).toPromise(), + 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()); + + it('properly initializes session storage and 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) + ); + + 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', () => { + let authHandler: AuthenticationHandler; + let authenticate: jest.SpyInstance, [KibanaRequest]>; + let mockAuthToolkit: jest.Mocked; + beforeEach(async () => { + mockAuthToolkit = httpServiceMock.createAuthToolkit(); + + 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 = httpServerMock.createKibanaRequest(); + + 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 = httpServerMock.createKibanaRequest(); + 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(httpServerMock.createKibanaRequest(), 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(httpServerMock.createKibanaRequest(), 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(httpServerMock.createKibanaRequest(), 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 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(originalError, ['Negotiate'])); + + await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit); + + expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); + const [[error]] = mockAuthToolkit.rejected.mock.calls; + expect(error.message).toBe(originalError.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(httpServerMock.createKibanaRequest(), 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(httpServerMock.createKibanaRequest())).resolves.toBe(null); + }); + + it('fails if `authenticate` call fails', async () => { + const failureReason = new Error('Something went wrong'); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + + await expect(getCurrentUser(httpServerMock.createKibanaRequest())).rejects.toBe( + failureReason + ); + }); + + it('returns result of `authenticate` call.', async () => { + const mockUser = mockAuthenticatedUser(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockUser); + + await expect(getCurrentUser(httpServerMock.createKibanaRequest())).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(httpServerMock.createKibanaRequest())).resolves.toBe(true); + }); + + it('returns `true` if `authenticate` succeeds.', async () => { + const mockUser = mockAuthenticatedUser(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockUser); + + await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(true); + }); + + it('returns `false` if `authenticate` fails with 401.', async () => { + 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 = new errors.BadRequest(); + 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 new file mode 100644 index 0000000000000..5e4568c485d59 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -0,0 +1,143 @@ +/* + * 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 { + ClusterClient, + CoreSetup, + KibanaRequest, + LoggerFactory, +} 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'; + +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 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()), + }), + }); + + authLogger.debug('Successfully initialized authenticator.'); + + 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(); + } + + 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 as unknown) as Record, + 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()); + }); + + authLogger.debug('Successfully registered core authentication handler.'); + + 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/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts new file mode 100644 index 0000000000000..8e7410ddec077 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -0,0 +1,36 @@ +/* + * 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 { ScopedClusterClient } from '../../../../../../src/core/server'; +import { Tokens } from '../tokens'; +import { loggingServiceMock, httpServiceMock } from '../../../../../../src/core/server/mocks'; + +export type MockAuthenticationProviderOptions = ReturnType< + typeof mockAuthenticationProviderOptions +>; + +export function mockScopedClusterClient( + client: MockAuthenticationProviderOptions['client'], + requestMatcher: sinon.SinonMatcher = sinon.match.any +) { + 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: { 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 new file mode 100644 index 0000000000000..940cba016d1ac --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -0,0 +1,92 @@ +/* + * 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 { + 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 { Tokens } from '../tokens'; + +/** + * Represents available provider options. + */ +export interface AuthenticationProviderOptions { + basePath: HttpServiceSetup['basePath']; + client: PublicMethodsOf; + logger: Logger; + tokens: PublicMethodsOf; +} + +/** + * Represents available provider specific options. + */ +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) { + this.logger = options.logger; + } + + /** + * 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. + */ + async login( + request: KibanaRequest, + loginAttempt: unknown, + state?: unknown + ): 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 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/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts similarity index 52% 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 index 88ae1d76f5b57..57ec808151f70 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -5,34 +5,80 @@ */ import sinon from 'sinon'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; -import { mockAuthenticationProviderOptions } from './base.mock'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +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 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( + httpServerMock.createKibanaRequest(), + 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 = httpServerMock.createKibanaRequest(); + 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. const authenticationResult = await provider.authenticate( - requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }), + httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), null ); @@ -41,75 +87,51 @@ 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', - }), + httpServerMock.createKibanaRequest({ 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('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 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 = httpServerMock.createKibanaRequest({ + 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 request = httpServerMock.createKibanaRequest(); + 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,27 +139,29 @@ 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 () => { - 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 }); - sinon.assert.notCalled(callWithRequest); + sinon.assert.notCalled(mockOptions.client.asScoped); expect(request.headers.authorization).toBe('Bearer ***'); expect(authenticationResult.notHandled()).toBe(true); }); 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'); - 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 +170,45 @@ 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 = httpServerMock.createKibanaRequest({ + 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 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 deauthenticateResult = await provider.deauthenticate(request); + 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( '/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' @@ -215,8 +239,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 new file mode 100644 index 0000000000000..8dcd783e7bd36 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -0,0 +1,209 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +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 } from './base'; + +/** + * Utility class that knows how to decorate request with proper Basic authentication headers. + */ +export class BasicCredentials { + /** + * Takes provided `username` and `password`, transforms them into proper `Basic ***` authorization + * header and decorates passed request with it. + * @param request Request instance. + * @param username User name. + * @param password User password. + */ + public static decorateRequest( + request: T, + username: string, + password: string + ) { + const typeOfRequest = typeof request; + if (!request || typeOfRequest !== 'object') { + throw new Error('Request should be a valid object.'); + } + + if (!username || typeof username !== 'string') { + throw new Error('Username should be a valid non-empty string.'); + } + + if (!password || typeof password !== 'string') { + throw new Error('Password should be a valid non-empty string.'); + } + + const basicCredentials = Buffer.from(`${username}:${password}`).toString('base64'); + request.headers.authorization = `Basic ${basicCredentials}`; + return request; + } +} + +/** + * 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. + */ +interface ProviderState { + /** + * Content of the HTTP authorization header (`Basic base-64-of-username:password`) that is based + * on user credentials used at login time and that should be provided with every request to the + * Elasticsearch on behalf of the authenticated user. + */ + authorization?: string; +} + +/** + * 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: 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( + `${this.options.basePath.get(request)}${request.url.path}` + ); + authenticationResult = AuthenticationResult.redirectTo( + `${this.options.basePath.get(request)}/login?next=${nextURL}` + ); + } + + return authenticationResult; + } + + /** + * Redirects user to the login page preserving query string parameters. + * @param request Request instance. + */ + 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.get(request)}/login${queryString}` + ); + } + + /** + * Validates whether request contains `Basic ***` Authorization header and just passes it + * forward to Elasticsearch backend. + * @param request Request instance. + */ + private async authenticateViaHeader(request: KibanaRequest) { + this.logger.debug('Trying to authenticate via header.'); + + const authorization = request.headers.authorization; + 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.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); + return { + authenticationResult: AuthenticationResult.notHandled(), + headerNotRecognized: true, + }; + } + + try { + const user = await this.getUser(request); + + this.logger.debug('Request has been authenticated via header.'); + return { authenticationResult: AuthenticationResult.succeeded(user) }; + } catch (err) { + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); + return { authenticationResult: 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: KibanaRequest, { authorization }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); + + if (!authorization) { + this.logger.debug('Access token is not found in state.'); + return AuthenticationResult.notHandled(); + } + + try { + const authHeaders = { authorization }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + return AuthenticationResult.failed(err); + } + } +} 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 83% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts rename to x-pack/plugins/security/server/authentication/providers/index.ts index a3a0c6192baa4..a1b71e5106fd5 100644 --- a/x-pack/legacy/plugins/security/server/lib/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 new file mode 100644 index 0000000000000..6219725860e5f --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -0,0 +1,478 @@ +/* + * 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 { errors } from 'elasticsearch'; +import sinon from 'sinon'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { + MockAuthenticationProviderOptions, + mockAuthenticationProviderOptions, + mockScopedClusterClient, +} from './base.mock'; + +import { KerberosAuthenticationProvider } from './kerberos'; +import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server/elasticsearch'; + +describe('KerberosAuthenticationProvider', () => { + let provider: KerberosAuthenticationProvider; + let mockOptions: MockAuthenticationProviderOptions; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions(); + provider = new KerberosAuthenticationProvider(mockOptions); + }); + + describe('`authenticate` method', () => { + it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic some:credentials' }, + }); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + const authenticationResult = await provider.authenticate(request, tokenPair); + + 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 that can be authenticated without `Negotiate` header.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves({}); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle requests if backend does not support Kerberos.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); + + const authenticationResult = await provider.authenticate(request, null); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('fails if state is present, but backend does not support Kerberos.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; + + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + + const authenticationResult = await provider.authenticate(request, tokenPair); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); + expect(authenticationResult.challenges).toBeUndefined(); + }); + + it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects( + ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) + ); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); + expect(authenticationResult.challenges).toEqual(['Negotiate']); + }); + + it('fails if request authentication is failed with non-401 error.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(new errors.ServiceUnavailable()); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('status', 503); + expect(authenticationResult.challenges).toBeUndefined(); + }); + + it('gets an token pair in exchange to SPNEGO one and stores it in the state.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'negotiate spnego' }, + }); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + 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( + mockOptions.client.callAsInternalUser, + 'shield.getAccessToken', + { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + ); + + 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', + }); + }); + + it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'negotiate spnego' }, + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockOptions.client.callAsInternalUser + .withArgs('shield.getAccessToken') + .rejects(failureReason); + + const authenticationResult = await provider.authenticate(request); + + 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); + expect(authenticationResult.error).toBe(failureReason); + expect(authenticationResult.challenges).toBeUndefined(); + }); + + it('fails if could not retrieve user using the new access token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'negotiate spnego' }, + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); + + 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( + mockOptions.client.callAsInternalUser, + 'shield.getAccessToken', + { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + ); + + expect(request.headers.authorization).toBe('negotiate spnego'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + expect(authenticationResult.challenges).toBeUndefined(); + }); + + it('succeeds if state contains a valid token.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + 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).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 = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); + + mockOptions.tokens.refresh + .withArgs(tokenPair.refreshToken) + .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer newfoo' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + 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).not.toHaveProperty('authorization'); + }); + + it('fails if token from the state is rejected because of unknown reason.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + const failureReason = new errors.InternalServerError('Token is not valid!'); + 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(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 = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; + + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .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); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); + expect(authenticationResult.challenges).toEqual(['Negotiate']); + }); + + it('fails with `Negotiate` challenge if both access and refresh token documents are missing and backend supports Kerberos.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const tokenPair = { accessToken: 'missing-token', refreshToken: 'missing-refresh-token' }; + + 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' } }, + }); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .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); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); + expect(authenticationResult.challenges).toEqual(['Negotiate']); + }); + + it('succeeds if `authorization` contains a valid token.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-valid-token' }, + }); + + 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(); + }); + + it('fails if token from `authorization` header is rejected.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-invalid-token' }, + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); + + const authenticationResult = await provider.authenticate(request); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-invalid-token' }, + }); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + }); + + describe('`logout` method', () => { + it('returns `notHandled` if state is not presented.', async () => { + const request = httpServerMock.createKibanaRequest(); + + let deauthenticateResult = await provider.logout(request); + expect(deauthenticateResult.notHandled()).toBe(true); + + deauthenticateResult = await provider.logout(request, null); + expect(deauthenticateResult.notHandled()).toBe(true); + + sinon.assert.notCalled(mockOptions.tokens.invalidate); + }); + + it('fails if `tokens.invalidate` fails', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + const failureReason = new Error('failed to delete token'); + mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); + + const authenticationResult = await provider.logout(request, tokenPair); + + sinon.assert.calledOnce(mockOptions.tokens.invalidate); + sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); + + const authenticationResult = await provider.logout(request, 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 new file mode 100644 index 0000000000000..2f71962cbe5b5 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -0,0 +1,279 @@ +/* + * 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 { + ElasticsearchError, + ElasticsearchErrorHelpers, + KibanaRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { BaseAuthenticationProvider } from './base'; +import { Tokens, TokenPair } from '../tokens'; + +/** + * The state supported by the provider. + */ +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: KibanaRequest) { + const authorization = request.headers.authorization; + if (!authorization || typeof authorization !== 'string') { + return ''; + } + + return authorization.split(/\s+/)[0].toLowerCase(); +} + +/** + * Provider that supports Kerberos request authentication. + */ +export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Performs Kerberos 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}.`); + + const authenticationScheme = getRequestAuthenticationScheme(request); + if ( + authenticationScheme && + (authenticationScheme !== 'negotiate' && authenticationScheme !== 'bearer') + ) { + this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + return AuthenticationResult.notHandled(); + } + + let authenticationResult = AuthenticationResult.notHandled(); + if (authenticationScheme) { + // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. + authenticationResult = + authenticationScheme === 'bearer' + ? await this.authenticateWithBearerScheme(request) + : await this.authenticateWithNegotiateScheme(request); + } + + if (state && authenticationResult.notHandled()) { + authenticationResult = await this.authenticateViaState(request, state); + if ( + authenticationResult.failed() && + Tokens.isAccessTokenExpiredError(authenticationResult.error) + ) { + authenticationResult = await this.authenticateViaRefreshToken(request, state); + } + } + + // If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can + // start authentication mechanism negotiation, otherwise just return authentication result we have. + return authenticationResult.notHandled() + ? await this.authenticateViaSPNEGO(request, state) + : authenticationResult; + } + + /** + * Invalidates access token retrieved in exchange for SPNEGO token if it exists. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); + + if (!state) { + this.logger.debug('There is no access token invalidate.'); + return DeauthenticationResult.notHandled(); + } + + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); + return DeauthenticationResult.failed(err); + } + + return DeauthenticationResult.redirectTo('/logged_out'); + } + + /** + * Tries to authenticate request with `Negotiate ***` Authorization header by passing it to the Elasticsearch backend to + * get an access token in exchange. + * @param request Request instance. + */ + private async authenticateWithNegotiateScheme(request: KibanaRequest) { + this.logger.debug('Trying to authenticate request using "Negotiate" authentication scheme.'); + + 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.callAsInternalUser('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, + }); + } catch (err) { + this.logger.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); + return AuthenticationResult.failed(err); + } + + this.logger.debug('Get token API request to Elasticsearch successful'); + + try { + // 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, { + authHeaders, + state: { accessToken: tokens.access_token, refreshToken: tokens.refresh_token }, + }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via access token: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend. + * @param request Request instance. + */ + private async authenticateWithBearerScheme(request: KibanaRequest) { + this.logger.debug('Trying to authenticate request using "Bearer" authentication scheme.'); + + try { + const user = await this.getUser(request); + + this.logger.debug('Request has been authenticated using "Bearer" authentication scheme.'); + return AuthenticationResult.succeeded(user); + } catch (err) { + this.logger.debug( + `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to extract access token from 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: KibanaRequest, { accessToken }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); + + if (!accessToken) { + this.logger.debug('Access token is not found in state.'); + return AuthenticationResult.notHandled(); + } + + try { + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * This method is only called when authentication via access token stored in the state failed because of expired + * token. So we should use refresh token, that is also stored in the state, to extend expired access token and + * authenticate user with it. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) { + this.logger.debug('Trying to refresh access token.'); + + let refreshedTokenPair: TokenPair | null; + try { + refreshedTokenPair = await this.options.tokens.refresh(state.refreshToken); + } catch (err) { + return AuthenticationResult.failed(err); + } + + // If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. + if (refreshedTokenPair === null) { + this.logger.debug( + 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.' + ); + return this.authenticateViaSPNEGO(request, state); + } + + try { + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); + } catch (err) { + this.logger.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to query Elasticsearch and see if we can rely on SPNEGO to authenticate user. + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + 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 elasticsearchError: ElasticsearchError; + try { + 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) { + // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch + // session cookie in this case. + if (!ElasticsearchErrorHelpers.isNotAuthorizedError(err)) { + return AuthenticationResult.failed(err); + } + + elasticsearchError = err; + } + + const challenges = ([] as string[]).concat( + elasticsearchError.output.headers['WWW-Authenticate'] + ); + + if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) { + this.logger.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`); + return AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']); + } + + 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. + // In this case we should reply with the `401` error and allow Authenticator to clear the cookie. + // Otherwise give a chance to the next authentication provider to authenticate request. + return state + ? AuthenticationResult.failed(Boom.unauthorized()) + : AuthenticationResult.notHandled(); + } +} 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 56% 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 index 78a2eee0e5408..c9a0e4350d886 100644 --- a/x-pack/legacy/plugins/security/server/lib/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 { mockAuthenticationProviderOptions } from './base.mock'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +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); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); + describe('`login` method', () => { + it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => { + const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' }); - 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/`, - }); - }); - - 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` }, + nextURL: '/base-path/', }); - - 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({ + const request = httpServerMock.createKibanaRequest({ 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 = httpServerMock.createKibanaRequest({ 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 = httpServerMock.createKibanaRequest({ 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( @@ -234,43 +154,108 @@ 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', - 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); }); it('fails if code is invalid.', async () => { - const request = requestFixture({ + const request = httpServerMock.createKibanaRequest({ 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 = httpServerMock.createKibanaRequest({ 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 = httpServerMock.createKibanaRequest({ 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', }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - }, + 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 = httpServerMock.createKibanaRequest({ 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(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + body: { realm: `oidc1` }, }); expect(authenticationResult.failed()).toBe(true); @@ -278,81 +263,92 @@ describe('OIDCAuthenticationProvider', () => { }); it('succeeds if state contains a valid token.', async () => { - const user = { username: 'user' }; - const request = requestFixture(); - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request, { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + 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 () => { - 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', 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 request = httpServerMock.createKibanaRequest(); + 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 request = requestFixture(); + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); 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', @@ -361,21 +357,21 @@ 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' }; - 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 +381,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 = httpServerMock.createKibanaRequest({ 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 +395,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,22 +421,22 @@ describe('OIDCAuthenticationProvider', () => { expect(authenticationResult.state).toEqual({ state: 'statevalue', nonce: 'noncevalue', - nextURL: `/s/foo/some-path`, + nextURL: '/base-path/s/foo/some-path', }); }); 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' }; - 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 +448,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 = httpServerMock.createKibanaRequest({ 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 = httpServerMock.createKibanaRequest({ 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 +481,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 = httpServerMock.createKibanaRequest({ 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,37 +507,39 @@ 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(); + const request = httpServerMock.createKibanaRequest(); - 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 () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-oidc-token'; 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 }, }); @@ -541,41 +548,43 @@ 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'; - 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 () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); 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/legacy/plugins/security/server/lib/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts similarity index 63% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts rename to x-pack/plugins/security/server/authentication/providers/oidc.ts index 358c0322bc3ff..f7695a060bbdf 100644 --- a/x-pack/legacy/plugins/security/server/lib/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 new file mode 100644 index 0000000000000..4d4fa796851d0 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -0,0 +1,950 @@ +/* + * 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 sinon from 'sinon'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { + MockAuthenticationProviderOptions, + mockAuthenticationProviderOptions, + mockScopedClusterClient, +} from './base.mock'; + +import { SAMLAuthenticationProvider } from './saml'; + +describe('SAMLAuthenticationProvider', () => { + let provider: SAMLAuthenticationProvider; + let mockOptions: MockAuthenticationProviderOptions; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions(); + provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm' }); + }); + + it('throws if `realm` option is not specified', () => { + const providerOptions = mockAuthenticationProviderOptions(); + + expect(() => new SAMLAuthenticationProvider(providerOptions)).toThrowError( + 'Realm name must be specified' + ); + expect(() => new SAMLAuthenticationProvider(providerOptions, {})).toThrowError( + 'Realm name must be specified' + ); + expect(() => new SAMLAuthenticationProvider(providerOptions, { realm: '' })).toThrowError( + 'Realm name must be specified' + ); + }); + + describe('`login` method', () => { + it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', nextURL: '/test-base-path/some-path' } + ); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.samlAuthenticate', + { body: { ids: ['some-request-id'], content: 'saml-response-xml' } } + ); + + 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 SAML Response payload is presented but state does not contain SAML Request token.', async () => { + const request = httpServerMock.createKibanaRequest(); + + 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('fails if SAML Response payload is presented but state does not contain redirect URL.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id' } + ); + + 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('redirects to the default location if state is not presented.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + access_token: 'idp-initiated-login-token', + refresh_token: 'idp-initiated-login-refresh-token', + }); + + const authenticationResult = await provider.login(request, { + samlResponse: 'saml-response-xml', + }); + + 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({ + accessToken: 'idp-initiated-login-token', + refreshToken: 'idp-initiated-login-refresh-token', + }); + }); + + it('fails if SAML Response is rejected.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const failureReason = new Error('SAML response is stale!'); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlAuthenticate') + .rejects(failureReason); + + const authenticationResult = await provider.login( + request, + { samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', nextURL: '/test-base-path/some-path' } + ); + + 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); + }); + + describe('IdP initiated login with existing session', () => { + it('fails if new SAML Response is rejected.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const user = mockAuthenticatedUser(); + mockScopedClusterClient(mockOptions.client) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + 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); + }); + + it('fails if token received in exchange to new SAML Response is rejected.', async () => { + const request = httpServerMock.createKibanaRequest(); + + // 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); + }); + + it('fails if fails to invalidate existing access/refresh tokens.', async () => { + const request = httpServerMock.createKibanaRequest(); + 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('redirects to the home page if new SAML Response is for the same user.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { + accessToken: 'existing-valid-token', + refreshToken: 'existing-valid-refresh-token', + }; + + const user = { username: 'user', authentication_realm: { name: 'saml1' } }; + 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' }); + + 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 = httpServerMock.createKibanaRequest(); + 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: 'new-user', authentication_realm: { name: 'saml1' } }; + 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'); + }); + + it('redirects to `overwritten_session` if new SAML Response is for another realm.', async () => { + const request = httpServerMock.createKibanaRequest(); + 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 = httpServerMock.createKibanaRequest({ 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 = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic some:credentials' }, + }); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }); + + sinon.assert.notCalled(mockOptions.client.asScoped); + expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('redirects non-AJAX request that can not be authenticated to the IdP.', async () => { + const request = httpServerMock.createKibanaRequest({ 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( + 'https://idp-host/path/login?SAMLRequest=some%20request%20' + ); + expect(authenticationResult.state).toEqual({ + requestId: 'some-request-id', + nextURL: `/base-path/s/foo/some-path`, + }); + }); + + it('fails if SAML request preparation fails.', async () => { + const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); + + const failureReason = new Error('Realm is misconfigured!'); + mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); + + const authenticationResult = await provider.authenticate(request, null); + + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('succeeds if state contains a valid token.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + 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).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 = httpServerMock.createKibanaRequest(); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + const failureReason = { statusCode: 500, message: 'Token is not valid!' }; + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .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); + }); + + it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects({ statusCode: 401 }); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: 'Bearer new-access-token' } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .resolves(user); + + 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).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', + refreshToken: 'new-refresh-token', + }); + }); + + it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; + + 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.', + }; + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(refreshFailureReason); + }); + + it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects({ statusCode: 401 }); + + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toEqual( + Boom.badRequest('Both access and refresh tokens are expired.') + ); + }); + + it('initiates SAML handshake for non-AJAX requests if access token document is missing.', async () => { + 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({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }); + + 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' } }, + }); + + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe( + 'https://idp-host/path/login?SAMLRequest=some%20request%20' + ); + expect(authenticationResult.state).toEqual({ + requestId: 'some-request-id', + nextURL: `/base-path/s/foo/some-path`, + }); + }); + + it('initiates SAML handshake for non-AJAX requests if refresh token is expired.', async () => { + 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({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }); + + mockScopedClusterClient( + mockOptions.client, + sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) + ) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects({ statusCode: 401 }); + + mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + + const authenticationResult = await provider.authenticate(request, tokenPair); + + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe( + 'https://idp-host/path/login?SAMLRequest=some%20request%20' + ); + expect(authenticationResult.state).toEqual({ + requestId: 'some-request-id', + nextURL: `/base-path/s/foo/some-path`, + }); + }); + + it('succeeds if `authorization` contains a valid token.', async () => { + const user = mockAuthenticatedUser(); + const authorization = 'Bearer some-valid-token'; + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + + 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 authorization = 'Bearer some-invalid-token'; + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + + const failureReason = { statusCode: 401 }; + mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) + .callAsCurrentUser.withArgs('shield.authenticate') + .rejects(failureReason); + + const authenticationResult = await provider.authenticate(request); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + 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 = httpServerMock.createKibanaRequest({ 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, { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + }); + + describe('`logout` method', () => { + it('returns `notHandled` if state is not presented or does not include access token.', async () => { + const request = httpServerMock.createKibanaRequest(); + + let deauthenticateResult = await provider.logout(request); + expect(deauthenticateResult.notHandled()).toBe(true); + + deauthenticateResult = await provider.logout(request, {} as any); + expect(deauthenticateResult.notHandled()).toBe(true); + + deauthenticateResult = await provider.logout(request, { somethingElse: 'x' } as any); + expect(deauthenticateResult.notHandled()).toBe(true); + + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + }); + + it('fails if SAML logout call fails.', async () => { + const request = httpServerMock.createKibanaRequest(); + const accessToken = 'x-saml-token'; + const refreshToken = 'x-saml-refresh-token'; + + const failureReason = new Error('Realm is misconfigured!'); + mockOptions.client.callAsInternalUser.withArgs('shield.samlLogout').rejects(failureReason); + + const authenticationResult = await provider.logout(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + body: { token: accessToken, refresh_token: refreshToken }, + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('fails if SAML invalidate call fails.', async () => { + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); + + const failureReason = new Error('Realm is misconfigured!'); + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .rejects(failureReason); + + const authenticationResult = await provider.logout(request); + + 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); + }); + + it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => { + const request = httpServerMock.createKibanaRequest(); + const accessToken = 'x-saml-token'; + const refreshToken = 'x-saml-refresh-token'; + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlLogout') + .resolves({ redirect: null }); + + const authenticationResult = await provider.logout(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + body: { token: accessToken, refresh_token: refreshToken }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/logged_out'); + }); + + it('redirects to /logged_out if `redirect` field in SAML logout response is not defined.', async () => { + const request = httpServerMock.createKibanaRequest(); + const accessToken = 'x-saml-token'; + const refreshToken = 'x-saml-refresh-token'; + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlLogout') + .resolves({ redirect: undefined }); + + const authenticationResult = await provider.logout(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + body: { token: accessToken, refresh_token: refreshToken }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/logged_out'); + }); + + it('relies on SAML logout if query string is not empty, but does not include SAMLRequest.', async () => { + const request = httpServerMock.createKibanaRequest({ + query: { Whatever: 'something unrelated' }, + }); + const accessToken = 'x-saml-token'; + const refreshToken = 'x-saml-refresh-token'; + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlLogout') + .resolves({ redirect: null }); + + const authenticationResult = await provider.logout(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + body: { token: accessToken, refresh_token: refreshToken }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/logged_out'); + }); + + it('relies on SAML invalidate call even if access token is presented.', async () => { + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .resolves({ redirect: null }); + + const authenticationResult = await provider.logout(request, { + accessToken: 'x-saml-token', + refreshToken: 'x-saml-refresh-token', + }); + + 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'); + }); + + it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => { + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .resolves({ redirect: null }); + + const authenticationResult = await provider.logout(request); + + 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'); + }); + + it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .resolves({ redirect: undefined }); + + const authenticationResult = await provider.logout(request); + + 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'); + }); + + it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { + const request = httpServerMock.createKibanaRequest(); + const accessToken = 'x-saml-token'; + const refreshToken = 'x-saml-refresh-token'; + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlLogout') + .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); + + const authenticationResult = await provider.logout(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); + }); + + it('redirects user to the IdP if SLO is supported by IdP in case of IdP initiated logout.', async () => { + const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); + + mockOptions.client.callAsInternalUser + .withArgs('shield.samlInvalidate') + .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); + + const authenticationResult = await provider.logout(request, { + accessToken: 'x-saml-token', + refreshToken: 'x-saml-refresh-token', + }); + + 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/legacy/plugins/security/server/lib/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts similarity index 61% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts rename to x-pack/plugins/security/server/authentication/providers/saml.ts index c6209abc26bb9..0ed4fe0a2eafc 100644 --- a/x-pack/legacy/plugins/security/server/lib/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 { AuthenticatedUser } from '../../../../common/model'; +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/legacy/plugins/security/server/lib/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts similarity index 53% 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 index 4cb088aa00d0b..8eb20447c7e2c 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -7,23 +7,101 @@ 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 { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +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 = httpServerMock.createKibanaRequest(); + const user = mockAuthenticatedUser(); - provider = new TokenAuthenticationProvider(providerOptions); + 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 = httpServerMock.createKibanaRequest(); + const credentials = { username: 'user', password: 'password' }; + + 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 = httpServerMock.createKibanaRequest(); + 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', () => { @@ -31,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 ); @@ -40,78 +118,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' }), + httpServerMock.createKibanaRequest({ 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 request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + 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 request = httpServerMock.createKibanaRequest(); 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 +160,59 @@ 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 request = requestFixture(); + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); 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 request = httpServerMock.createKibanaRequest({ + 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); }); @@ -176,84 +220,34 @@ 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 user = { username: 'user' }; + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + 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 request = httpServerMock.createKibanaRequest({ 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(); @@ -262,20 +256,18 @@ 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'); - 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(); @@ -284,23 +276,22 @@ 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' }; - 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); @@ -310,77 +301,81 @@ 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' }; - 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(); }); 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' }; - 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(); }); 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' }; - 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); @@ -392,32 +387,31 @@ 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' }; - 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,67 +421,67 @@ describe('TokenAuthenticationProvider', () => { }); }); - describe('`deauthenticate` method', () => { + 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.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); }); 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'); - 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); }); it('redirects to /login if tokens are invalidated successfully', async () => { - const request = requestFixture(); + const request = httpServerMock.createKibanaRequest(); 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'); }); 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' }; - 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'); + expect(authenticationResult.redirectURL).toBe('/base-path/login?yep=nope'); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts new file mode 100644 index 0000000000000..4712d46a83611 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -0,0 +1,255 @@ +/* + * 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 { 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 { 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. + */ +type ProviderState = TokenPair; + +/** + * Provider that supports token-based request authentication. + */ +export class TokenAuthenticationProvider extends BaseAuthenticationProvider { + /** + * 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 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 }, + }); + + 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 + 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); + if ( + authenticationResult.failed() && + Tokens.isAccessTokenExpiredError(authenticationResult.error) + ) { + authenticationResult = await this.authenticateViaRefreshToken(request, state); + } + } + + // finally, if authentication still can not be handled for this + // request/state combination, redirect to the login page if appropriate + if (authenticationResult.notHandled() && canRedirectRequest(request)) { + authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request)); + } + + return authenticationResult; + } + + /** + * Redirects user to the login page preserving query string parameters. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); + + if (!state) { + this.logger.debug('There are no access and refresh tokens to invalidate.'); + return DeauthenticationResult.notHandled(); + } + + this.logger.debug('Token-based logout has been initiated by the user.'); + + try { + await this.options.tokens.invalidate(state); + } catch (err) { + 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.get(request)}/login${queryString}` + ); + } + + /** + * Validates whether request contains `Bearer ***` Authorization header and just passes it + * forward to Elasticsearch backend. + * @param request Request instance. + */ + private async authenticateViaHeader(request: KibanaRequest) { + this.logger.debug('Trying to authenticate via header.'); + + const authorization = request.headers.authorization; + 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.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); + return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true }; + } + + try { + const user = await this.getUser(request); + + 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.logger.debug(`Failed to authenticate request via header: ${err.message}`); + return { authenticationResult: 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: KibanaRequest, { accessToken }: ProviderState) { + this.logger.debug('Trying to authenticate via state.'); + + try { + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * This method is only called when authentication via access token stored in the state failed because of expired + * token. So we should use refresh token, that is also stored in the state, to extend expired access token and + * authenticate user with it. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaRefreshToken( + request: KibanaRequest, + { refreshToken }: ProviderState + ) { + this.logger.debug('Trying to refresh access token.'); + + let refreshedTokenPair: TokenPair | null; + try { + refreshedTokenPair = await this.options.tokens.refresh(refreshToken); + } catch (err) { + return AuthenticationResult.failed(err); + } + + // If refresh token is no longer valid, then we should clear session and redirect user to the + // login page to re-authenticate, or fail if redirect isn't possible. + if (refreshedTokenPair === null) { + if (canRedirectRequest(request)) { + 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); + } + + return AuthenticationResult.failed( + Boom.badRequest('Both access and refresh tokens are expired.') + ); + } + + try { + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); + } catch (err) { + this.logger.debug( + `Failed to authenticate user using newly refreshed access token: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } + + /** + * Constructs login page URL using current url path as `next` query string parameter. + * @param request Request instance. + */ + 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/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts new file mode 100644 index 0000000000000..baf3b1f03bc9c --- /dev/null +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -0,0 +1,230 @@ +/* + * 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 { errors } from 'elasticsearch'; + +import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; + +import { ClusterClient, ElasticsearchErrorHelpers } from '../../../../../src/core/server'; +import { Tokens } from './tokens'; + +describe('Tokens', () => { + let tokens: Tokens; + let mockClusterClient: jest.Mocked>; + beforeEach(() => { + mockClusterClient = elasticsearchServiceMock.createClusterClient(); + + 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', () => { + const nonExpirationErrors = [ + {}, + new Error(), + new errors.InternalServerError(), + new errors.Forbidden(), + { statusCode: 500, body: { error: { reason: 'some unknown reason' } } }, + ]; + for (const error of nonExpirationErrors) { + expect(Tokens.isAccessTokenExpiredError(error)).toBe(false); + } + + const expirationErrors = [ + { statusCode: 401 }, + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()), + new errors.AuthenticationException(), + { + statusCode: 500, + body: { error: { reason: 'token document is missing and must be present' } }, + }, + ]; + for (const error of expirationErrors) { + expect(Tokens.isAccessTokenExpiredError(error)).toBe(true); + } + }); + + describe('refresh()', () => { + const refreshToken = 'some-refresh-token'; + + it('throws if API call fails with unknown reason', async () => { + const refreshFailureReason = new errors.ServiceUnavailable('Server is not available'); + 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 = new errors.BadRequest(); + 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.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 }, + }); + }); + }); + + describe('invalidate()', () => { + it('throws if call to delete access token responds with an error', async () => { + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + const failureReason = new Error('failed to delete token'); + mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + if (args && args.body && args.body.token) { + return Promise.reject(failureReason); + } + + return Promise.resolve({ invalidated_tokens: 1 }); + }); + + await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + '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'); + mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + if (args && args.body && args.body.refresh_token) { + return Promise.reject(failureReason); + } + + return Promise.resolve({ invalidated_tokens: 1 }); + }); + + await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); + }); + + it('invalidates all provided tokens', async () => { + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); + + await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); + }); + + it('invalidates only access token if only access token is provided', async () => { + const tokenPair = { accessToken: 'foo' }; + + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); + + await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + }); + + it('invalidates only refresh token if only refresh token is provided', async () => { + const tokenPair = { refreshToken: 'foo' }; + + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); + + await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + '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' }; + + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 0 }); + + await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + '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' }; + + mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 5 }); + + await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts similarity index 81% rename from x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts rename to x-pack/plugins/security/server/authentication/tokens.ts index 15702036ce6d5..ae77d165a2ff5 100644 --- a/x-pack/legacy/plugins/security/server/lib/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 new file mode 100644 index 0000000000000..991841b4fd399 --- /dev/null +++ b/x-pack/plugins/security/server/config.test.ts @@ -0,0 +1,247 @@ +/* + * 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('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]: a value wasn't expected to be present"`); + }); + }); + + 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(() => + ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.saml.realm]: expected value of type [string] but got [undefined]"` + ); + + 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]: a value wasn't expected to be present"`); + }); + }); +}); + +describe('createConfig$()', () => { + 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)); + + const contextMock = coreMock.createPluginInitializerContext({}); + const config = await createConfig$(contextMock, true) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'ab'.repeat(16), secureCookies: true }); + + 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", + ], +] +`); + }); + + it('should log a warning if SSL is not configured', async () => { + const contextMock = coreMock.createPluginInitializerContext({ + encryptionKey: 'a'.repeat(32), + secureCookies: false, + }); + + const config = await createConfig$(contextMock, false) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: false }); + + expect(loggingServiceMock.collect(contextMock.logger).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', async () => { + const contextMock = coreMock.createPluginInitializerContext({ + encryptionKey: 'a'.repeat(32), + secureCookies: true, + }); + + const config = await createConfig$(contextMock, false) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); + + 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.", + ], +] +`); + }); + + it('should set xpack.security.secureCookies if SSL is configured', async () => { + const contextMock = coreMock.createPluginInitializerContext({ + encryptionKey: 'a'.repeat(32), + secureCookies: false, + }); + + const config = await createConfig$(contextMock, true) + .pipe(first()) + .toPromise(); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); + + expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts new file mode 100644 index 0000000000000..8df8641dddbed --- /dev/null +++ b/x-pack/plugins/security/server/config.ts @@ -0,0 +1,86 @@ +/* + * 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 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.never() + ); + +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/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 diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts new file mode 100644 index 0000000000000..b62cc84973960 --- /dev/null +++ b/x-pack/plugins/security/server/index.ts @@ -0,0 +1,27 @@ +/* + * 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 { 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. 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, + AuthenticationResult, + 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 0000000000000..3713257fead8a --- /dev/null +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { 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: MockedKeys; + 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; + + mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockCoreSetup.elasticsearch.createClient.mockReturnValue( + (mockClusterClient as unknown) as jest.Mocked + ); + }); + + 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 0000000000000..9e9c0aba40818 --- /dev/null +++ b/x-pack/plugins/security/server/plugin.ts @@ -0,0 +1,113 @@ +/* + * 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 } from 'rxjs/operators'; +import { + ClusterClient, + CoreSetup, + KibanaRequest, + Logger, + PluginInitializerContext, + RecursiveReadonly, +} 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'; +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: string[] }; + }>; + + 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 = core.elasticsearch.createClient('security', { + plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], + }); + + 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; + } + } +}