diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 2983ec474b9e01..35a902e384599a 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -258,6 +258,10 @@ exports.Cluster = class Cluster { this._process = execa(ES_BIN, args, { cwd: installPath, + env: { + ...process.env, + ...(options.esEnvVars || {}), + }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/kbn-test/src/es/es_test_cluster.js b/packages/kbn-test/src/es/es_test_cluster.js index c8db170cc59e17..53153a21e153b2 100644 --- a/packages/kbn-test/src/es/es_test_cluster.js +++ b/packages/kbn-test/src/es/es_test_cluster.js @@ -62,7 +62,7 @@ export function createEsTestCluster(options = {}) { return esFrom === 'snapshot' ? 3 * minute : 6 * minute; } - async start(esArgs = []) { + async start(esArgs = [], esEnvVars) { let installPath; if (esFrom === 'source') { @@ -87,6 +87,7 @@ export function createEsTestCluster(options = {}) { 'discovery.type=single-node', ...esArgs, ], + esEnvVars, }); } diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js index 7971bf94f802b6..2e290222b1a9da 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js @@ -27,6 +27,7 @@ export async function runElasticsearch({ config, options }) { const { log, esFrom } = options; const license = config.get('esTestCluster.license'); const esArgs = config.get('esTestCluster.serverArgs'); + const esEnvVars = config.get('esTestCluster.serverEnvVars'); const isSecurityEnabled = esArgs.includes('xpack.security.enabled=true'); const cluster = createEsTestCluster({ @@ -41,7 +42,7 @@ export async function runElasticsearch({ config, options }) { dataArchive: config.get('esTestCluster.dataArchive'), }); - await cluster.start(esArgs); + await cluster.start(esArgs, esEnvVars); if (isSecurityEnabled) { await setupUsers(log, config.get('servers.elasticsearch.port'), [ diff --git a/src/functional_test_runner/lib/config/schema.ts b/src/functional_test_runner/lib/config/schema.ts index 57c0b1135c3afc..8f43d729b1e10f 100644 --- a/src/functional_test_runner/lib/config/schema.ts +++ b/src/functional_test_runner/lib/config/schema.ts @@ -166,6 +166,7 @@ export const schema = Joi.object() license: Joi.string().default('oss'), from: Joi.string().default('snapshot'), serverArgs: Joi.array(), + serverEnvVars: Joi.object(), dataArchive: Joi.string(), }) .default(), diff --git a/x-pack/plugins/security/server/lib/__tests__/auth_redirect.js b/x-pack/plugins/security/server/lib/__tests__/auth_redirect.js index fbfcf3fcf3b36a..a96d4b5a008cc6 100644 --- a/x-pack/plugins/security/server/lib/__tests__/auth_redirect.js +++ b/x-pack/plugins/security/server/lib/__tests__/auth_redirect.js @@ -103,6 +103,31 @@ describe('lib/auth_redirect', function () { 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()) diff --git a/x-pack/plugins/security/server/lib/auth_redirect.js b/x-pack/plugins/security/server/lib/auth_redirect.js index 844847e38351dc..cbcd5ecaeb4790 100644 --- a/x-pack/plugins/security/server/lib/auth_redirect.js +++ b/x-pack/plugins/security/server/lib/auth_redirect.js @@ -26,25 +26,38 @@ export function authenticateFactory(server) { let authenticationResult; try { authenticationResult = await server.plugins.security.authenticate(request); - } catch(err) { + } catch (err) { server.log(['error', 'authentication'], err); return wrapError(err); } if (authenticationResult.succeeded()) { return h.authenticated({ credentials: authenticationResult.user }); - } else if (authenticationResult.redirected()) { + } + + 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(); - } else if (authenticationResult.failed()) { - server.log(['info', 'authentication'], `Authentication attempt failed: ${authenticationResult.error.message}`); - return wrapError(authenticationResult.error); - } else { - return Boom.unauthorized(); } + + if (authenticationResult.failed()) { + server.log( + ['info', 'authentication'], + `Authentication attempt failed: ${authenticationResult.error.message}` + ); + + const error = wrapError(authenticationResult.error); + if (authenticationResult.challenges) { + error.output.headers['WWW-Authenticate'] = authenticationResult.challenges; + } + + return error; + } + + return Boom.unauthorized(); }; } diff --git a/x-pack/plugins/security/server/lib/authentication/authentication_result.test.ts b/x-pack/plugins/security/server/lib/authentication/authentication_result.test.ts index 87e8060b10f1c0..b226ddd7d8b9a0 100644 --- a/x-pack/plugins/security/server/lib/authentication/authentication_result.test.ts +++ b/x-pack/plugins/security/server/lib/authentication/authentication_result.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from './authentication_result'; @@ -45,6 +46,28 @@ describe('AuthenticationResult', () => { expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); + + it('can provide `challenges` for `401` errors', () => { + const failureReason = Boom.unauthorized(); + const authenticationResult = AuthenticationResult.failed(failureReason, ['Negotiate']); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.notHandled()).toBe(false); + expect(authenticationResult.succeeded()).toBe(false); + expect(authenticationResult.redirected()).toBe(false); + + expect(authenticationResult.challenges).toEqual(['Negotiate']); + expect(authenticationResult.error).toBe(failureReason); + expect(authenticationResult.user).toBeUndefined(); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.redirectURL).toBeUndefined(); + }); + + it('can not provide `challenges` for non-`401` errors', () => { + expect(() => AuthenticationResult.failed(Boom.badRequest(), ['Negotiate'])).toThrowError( + 'Challenges can only be provided with `401 Unauthorized` errors.' + ); + }); }); describe('succeeded', () => { diff --git a/x-pack/plugins/security/server/lib/authentication/authentication_result.ts b/x-pack/plugins/security/server/lib/authentication/authentication_result.ts index d30a8592af630d..be443462688be9 100644 --- a/x-pack/plugins/security/server/lib/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/lib/authentication/authentication_result.ts @@ -8,6 +8,7 @@ * Represents status that `AuthenticationResult` can be in. */ import { AuthenticatedUser } from '../../../common/model'; +import { getErrorStatusCode } from '../errors'; enum AuthenticationResultStatus { /** @@ -40,6 +41,7 @@ enum AuthenticationResultStatus { */ interface AuthenticationOptions { error?: Error; + challenges?: string[]; redirectURL?: string; state?: unknown; user?: AuthenticatedUser; @@ -73,13 +75,21 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication fails. * @param error Error that occurred during authentication attempt. + * @param [challenges] Optional list of the challenges that will be returned to the user within + * `WWW-Authenticate` HTTP header. Multiple challenges will result in multiple headers (one per + * challenge) as it's better supported by the browsers than comma separated list within a single + * header. Challenges can only be set for errors with `401` error status. */ - public static failed(error: Error) { + public static failed(error: Error, challenges?: string[]) { if (!error) { throw new Error('Error should be specified.'); } - return new AuthenticationResult(AuthenticationResultStatus.Failed, { error }); + if (challenges != null && getErrorStatusCode(error) !== 401) { + throw new Error('Challenges can only be provided with `401 Unauthorized` errors.'); + } + + return new AuthenticationResult(AuthenticationResultStatus.Failed, { error, challenges }); } /** @@ -117,6 +127,13 @@ export class AuthenticationResult { return this.options.error; } + /** + * Challenges that need to be sent to the user within `WWW-Authenticate` HTTP header. + */ + public get challenges() { + return this.options.challenges; + } + /** * URL that should be used to redirect user to complete authentication only available * for `redirected` result). diff --git a/x-pack/plugins/security/server/lib/authentication/authenticator.ts b/x-pack/plugins/security/server/lib/authentication/authenticator.ts index f2bbacbfb12c9e..232ba41d2a7076 100644 --- a/x-pack/plugins/security/server/lib/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/lib/authentication/authenticator.ts @@ -12,6 +12,8 @@ import { AuthenticationProviderOptions, BaseAuthenticationProvider, BasicAuthenticationProvider, + KerberosAuthenticationProvider, + RequestWithLoginAttempt, SAMLAuthenticationProvider, TokenAuthenticationProvider, OIDCAuthenticationProvider, @@ -37,6 +39,7 @@ const providerMap = new Map< ) => BaseAuthenticationProvider >([ ['basic', BasicAuthenticationProvider], + ['kerberos', KerberosAuthenticationProvider], ['saml', SAMLAuthenticationProvider], ['token', TokenAuthenticationProvider], ['oidc', OIDCAuthenticationProvider], @@ -163,7 +166,7 @@ class Authenticator { * Performs request authentication using configured chain of authentication providers. * @param request Request instance. */ - async authenticate(request: Legacy.Request) { + async authenticate(request: RequestWithLoginAttempt) { assertRequest(request); const isSystemApiRequest = this.server.plugins.kibana.systemApi.isSystemApiRequest(request); @@ -227,7 +230,7 @@ class Authenticator { * Deauthenticates current request. * @param request Request instance. */ - async deauthenticate(request: Legacy.Request) { + async deauthenticate(request: RequestWithLoginAttempt) { assertRequest(request); const sessionValue = await this.getSessionValue(request); @@ -307,8 +310,10 @@ export async function initAuthenticator(server: Legacy.Server) { return loginAttempts.get(request); }); - server.expose('authenticate', (request: Legacy.Request) => authenticator.authenticate(request)); - server.expose('deauthenticate', (request: Legacy.Request) => + server.expose('authenticate', (request: RequestWithLoginAttempt) => + authenticator.authenticate(request) + ); + server.expose('deauthenticate', (request: RequestWithLoginAttempt) => authenticator.deauthenticate(request) ); server.expose('registerAuthScopeGetter', (scopeExtender: ScopesGetter) => diff --git a/x-pack/plugins/security/server/lib/authentication/providers/base.ts b/x-pack/plugins/security/server/lib/authentication/providers/base.ts index 01c8a260ee673f..837ca3e2554057 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/base.ts @@ -7,6 +7,14 @@ import { Legacy } from 'kibana'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { LoginAttempt } from '../login_attempt'; + +/** + * Describes a request complemented with `loginAttempt` method. + */ +export interface RequestWithLoginAttempt extends Legacy.Request { + loginAttempt: () => LoginAttempt; +} /** * Represents available provider options. @@ -40,7 +48,10 @@ export abstract class BaseAuthenticationProvider { * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - abstract authenticate(request: Legacy.Request, state?: unknown): Promise; + abstract authenticate( + request: RequestWithLoginAttempt, + state?: unknown + ): Promise; /** * Invalidates user session associated with the request. diff --git a/x-pack/plugins/security/server/lib/authentication/providers/basic.ts b/x-pack/plugins/security/server/lib/authentication/providers/basic.ts index 05d7ef28eee756..2d42cc8a52d393 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/basic.ts @@ -10,8 +10,7 @@ import { Legacy } from 'kibana'; import { canRedirectRequest } from '../../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { LoginAttempt } from '../login_attempt'; -import { BaseAuthenticationProvider } from './base'; +import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; /** * Utility class that knows how to decorate request with proper Basic authentication headers. @@ -24,7 +23,7 @@ export class BasicCredentials { * @param username User name. * @param password User password. */ - public static decorateRequest( + public static decorateRequest( request: T, username: string, password: string @@ -48,10 +47,6 @@ export class BasicCredentials { } } -type RequestWithLoginAttempt = Legacy.Request & { - loginAttempt: () => LoginAttempt; -}; - /** * The state supported by the provider. */ @@ -153,7 +148,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: Legacy.Request) { + private async authenticateViaHeader(request: RequestWithLoginAttempt) { this.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; @@ -189,7 +184,10 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState(request: Legacy.Request, { authorization }: ProviderState) { + private async authenticateViaState( + request: RequestWithLoginAttempt, + { authorization }: ProviderState + ) { this.debug('Trying to authenticate via state.'); if (!authorization) { diff --git a/x-pack/plugins/security/server/lib/authentication/providers/index.ts b/x-pack/plugins/security/server/lib/authentication/providers/index.ts index 058bbe3a7f85c9..a3a0c6192baa42 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/index.ts @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -export { BaseAuthenticationProvider, AuthenticationProviderOptions } from './base'; +export { + BaseAuthenticationProvider, + AuthenticationProviderOptions, + RequestWithLoginAttempt, +} from './base'; export { BasicAuthenticationProvider, BasicCredentials } from './basic'; +export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider } from './saml'; export { TokenAuthenticationProvider } from './token'; export { OIDCAuthenticationProvider } from './oidc'; diff --git a/x-pack/plugins/security/server/lib/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/lib/authentication/providers/kerberos.test.ts new file mode 100644 index 00000000000000..24456b3eccb923 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authentication/providers/kerberos.test.ts @@ -0,0 +1,326 @@ +/* + * 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; + beforeEach(() => { + const providerOptions = mockAuthenticationProviderOptions(); + callWithRequest = providerOptions.client.callWithRequest as sinon.SinonStub; + callWithInternalUser = providerOptions.client.callWithInternalUser as sinon.SinonStub; + + 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 authenticationResult = await provider.authenticate(request, { + accessToken: 'some-valid-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', + }); + + sinon.assert.notCalled(callWithRequest); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { + const request = requestFixture(); + callWithRequest.withArgs(request, 'shield.authenticate').resolves({}); + + 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(request, '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(); + callWithRequest.withArgs(request, 'shield.authenticate').rejects(Boom.unauthorized()); + + let authenticationResult = await provider.authenticate(request, { accessToken: 'token' }); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); + expect(authenticationResult.challenges).toBeUndefined(); + + callWithRequest + .withArgs(request, 'shield.authenticate') + .rejects(Boom.unauthorized(null, 'Basic')); + + authenticationResult = await provider.authenticate(request, { accessToken: 'token' }); + 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(request, '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(request, '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 an access token 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); + + callWithRequest + .withArgs(request, 'shield.getAccessToken') + .resolves({ access_token: 'some-token' }); + + const authenticationResult = await provider.authenticate(request); + + sinon.assert.calledWithExactly(callWithRequest, request, 'shield.getAccessToken', { + body: { grant_type: 'client_credentials' }, + }); + + 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' }); + }); + + 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(); + callWithRequest.withArgs(request, 'shield.getAccessToken').rejects(failureReason); + + const authenticationResult = await provider.authenticate(request); + + sinon.assert.calledWithExactly(callWithRequest, request, 'shield.getAccessToken', { + body: { grant_type: 'client_credentials' }, + }); + + 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); + + callWithRequest + .withArgs(request, 'shield.getAccessToken') + .resolves({ access_token: 'some-token' }); + + const authenticationResult = await provider.authenticate(request); + + sinon.assert.calledWithExactly(callWithRequest, request, 'shield.getAccessToken', { + body: { grant_type: 'client_credentials' }, + }); + + 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(); + + callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-valid-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 = Boom.internal('Token is not valid!'); + callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-invalid-token', + }); + + 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 token from the state is expired and backend supports Kerberos.', async () => { + const request = requestFixture(); + callWithRequest.rejects(Boom.unauthorized(null, 'Negotiate')); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'expired-token', + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); + expect(authenticationResult.challenges).toEqual(['Negotiate']); + }); + + it('fails with `Negotiate` challenge if access token document is missing and backend supports Kerberos.', async () => { + const request = requestFixture({ headers: {} }); + + 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' } }, + }) + .withArgs(sinon.match({ headers: {} }), 'shield.authenticate') + .rejects(Boom.unauthorized(null, 'Negotiate')); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'missing-token', + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); + expect(authenticationResult.challenges).toEqual(['Negotiate']); + }); + }); + + 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 `deleteAccessToken` call fails.', async () => { + const request = requestFixture(); + const accessToken = 'x-access-token'; + + const failureReason = new Error('Unknown error'); + callWithInternalUser.withArgs('shield.deleteAccessToken').rejects(failureReason); + + const authenticationResult = await provider.deauthenticate(request, { + accessToken, + }); + + sinon.assert.calledOnce(callWithInternalUser); + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { + body: { token: accessToken }, + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('invalidates access token and redirects to `/logged_out` page.', async () => { + const request = requestFixture(); + const accessToken = 'x-access-token'; + + callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 }); + + const authenticationResult = await provider.deauthenticate(request, { + accessToken, + }); + + sinon.assert.calledOnce(callWithInternalUser); + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { + body: { token: accessToken }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/logged_out'); + }); + }); +}); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/lib/authentication/providers/kerberos.ts new file mode 100644 index 00000000000000..0b98222c57db48 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authentication/providers/kerberos.ts @@ -0,0 +1,275 @@ +/* + * 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 { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; + +/** + * The state supported by the provider. + */ +interface ProviderState { + /** + * Access token users get in exchange for SPNEGO token and that should be provided with every + * request to Elasticsearch on behalf of the authenticated user. This token will eventually expire. + */ + accessToken: string; +} + +/** + * If request with access token fails with `401 Unauthorized` then this token is no + * longer valid and we should try to refresh it. Another use case that we should + * temporarily support (until elastic/elasticsearch#38866 is fixed) is when token + * document has been removed and ES responds with `500 Internal Server Error`. + * @param err Error returned from Elasticsearch. + */ +function isAccessTokenExpiredError(err?: any) { + const errorStatusCode = getErrorStatusCode(err); + return ( + errorStatusCode === 401 || + (errorStatusCode === 500 && + err && + err.body && + err.body.error && + err.body.error.reason === 'token document is missing and must be present') + ); +} + +/** + * 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') { + 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 = await this.authenticateViaHeader(request); + if (state && authenticationResult.notHandled()) { + authenticationResult = await this.authenticateViaState(request, state); + if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) { + authenticationResult = AuthenticationResult.notHandled(); + } + } + + // 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) { + this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + + if (!state || !state.accessToken) { + this.debug('There is no access token invalidate.'); + return DeauthenticationResult.notHandled(); + } + + try { + const { + invalidated_tokens: invalidatedAccessTokensCount, + } = await this.options.client.callWithInternalUser('shield.deleteAccessToken', { + body: { token: state.accessToken }, + }); + + if (invalidatedAccessTokensCount === 0) { + this.debug('User access token was already invalidated.'); + } else if (invalidatedAccessTokensCount === 1) { + this.debug('User access token has been successfully invalidated.'); + } else { + this.debug( + `${invalidatedAccessTokensCount} user access tokens were invalidated, this is unexpected.` + ); + } + } catch (err) { + this.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } + + return DeauthenticationResult.redirectTo('/logged_out'); + } + + /** + * Validates whether request contains `Negotiate ***` 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.notHandled(); + } + + // First attempt to exchange SPNEGO token for an access token. + let accessToken: string; + try { + accessToken = (await this.options.client.callWithRequest(request, 'shield.getAccessToken', { + body: { grant_type: 'client_credentials' }, + })).access_token; + } 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 ${accessToken}`; + + 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 }); + } 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 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); + } + } + + /** + * 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(request, '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/plugins/security/server/lib/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/lib/authentication/providers/oidc.test.ts index f7ecab4bcb8d6d..8a512c5a390ed1 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/oidc.test.ts @@ -6,6 +6,7 @@ import sinon from 'sinon'; import Boom from 'boom'; +import { LoginAttempt } from '../login_attempt'; import { mockAuthenticationProviderOptions } from './base.mock'; import { requestFixture } from '../../__tests__/__fixtures__/request'; @@ -34,6 +35,23 @@ describe('OIDCAuthenticationProvider', () => { 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); + 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' }); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/oidc.ts b/x-pack/plugins/security/server/lib/authentication/providers/oidc.ts index 7ed9c11915a388..04f25a5712d97c 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/oidc.ts @@ -15,6 +15,7 @@ import { AuthenticationProviderOptions, BaseAuthenticationProvider, AuthenticationProviderSpecificOptions, + RequestWithLoginAttempt, } from './base'; /** @@ -54,7 +55,7 @@ interface ProviderState { /** * Defines the shape of an incoming OpenID Connect Request */ -type OIDCIncomingRequest = Legacy.Request & { +type OIDCIncomingRequest = RequestWithLoginAttempt & { payload: { iss?: string; login_hint?: string; @@ -80,7 +81,7 @@ type OIDCIncomingRequest = Legacy.Request & { * an OpenID Connect Provider * @param request Request instance. */ -function isOIDCIncomingRequest(request: Legacy.Request): request is OIDCIncomingRequest { +function isOIDCIncomingRequest(request: RequestWithLoginAttempt): request is OIDCIncomingRequest { return ( (request.payload != null && !!(request.payload as Record).iss) || (request.query != null && @@ -134,7 +135,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: Legacy.Request, state?: ProviderState | null) { + public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { this.debug(`Trying to authenticate user request to ${request.url.path}.`); let { @@ -145,6 +146,11 @@ 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 (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) { @@ -250,10 +256,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * Initiates an authentication attempt by either providing the realm name or the issuer to Elasticsearch * * @param request Request instance. - * @param params + * @param params OIDC authentication parameters. + * @param [sessionState] Optional state object associated with the provider. */ private async initiateOIDCAuthentication( - request: Legacy.Request, + request: RequestWithLoginAttempt, params: { realm: string } | { iss: string; login_hint?: string }, sessionState?: ProviderState | null ) { @@ -305,7 +312,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: Legacy.Request) { + private async authenticateViaHeader(request: RequestWithLoginAttempt) { this.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; @@ -347,7 +354,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) { + private async authenticateViaState( + request: RequestWithLoginAttempt, + { accessToken }: ProviderState + ) { this.debug('Trying to authenticate via state.'); if (!accessToken) { @@ -385,7 +395,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: Legacy.Request, + request: RequestWithLoginAttempt, { refreshToken }: ProviderState ) { this.debug('Trying to refresh elasticsearch access token.'); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts index 47e9a567274c20..f1da15273ec074 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts @@ -8,6 +8,7 @@ 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'; @@ -33,6 +34,35 @@ describe('SAMLAuthenticationProvider', () => { 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' }); @@ -189,19 +219,6 @@ describe('SAMLAuthenticationProvider', () => { 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 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('fails if token from the state is rejected because of unknown reason.', async () => { const request = requestFixture(); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/saml.ts b/x-pack/plugins/security/server/lib/authentication/providers/saml.ts index 443f17dd0a5ea1..ab3dc8fd5b35e9 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/saml.ts @@ -11,7 +11,7 @@ import { getErrorStatusCode } from '../../errors'; import { AuthenticatedUser } from '../../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { BaseAuthenticationProvider } from './base'; +import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; /** * The state supported by the provider (for the SAML handshake or established session). @@ -50,7 +50,7 @@ interface SAMLRequestQuery { /** * Defines the shape of the request with a body containing SAML response. */ -type RequestWithSAMLPayload = Legacy.Request & { +type RequestWithSAMLPayload = RequestWithLoginAttempt & { payload: { SAMLResponse: string; RelayState?: string }; }; @@ -78,7 +78,7 @@ function isAccessTokenExpiredError(err?: any) { * @param request Request instance. */ function isRequestWithSAMLResponsePayload( - request: Legacy.Request + request: RequestWithLoginAttempt ): request is RequestWithSAMLPayload { return request.payload != null && !!(request.payload as any).SAMLResponse; } @@ -100,7 +100,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param [state] Optional state object associated with the provider. */ - public async authenticate(request: Legacy.Request, state?: ProviderState | null) { + public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) { this.debug(`Trying to authenticate user request to ${request.url.path}.`); let { @@ -112,6 +112,11 @@ 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 (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) { @@ -180,7 +185,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: Legacy.Request) { + private async authenticateViaHeader(request: RequestWithLoginAttempt) { this.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; @@ -355,7 +360,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) { + private async authenticateViaState( + request: RequestWithLoginAttempt, + { accessToken }: ProviderState + ) { this.debug('Trying to authenticate via state.'); if (!accessToken) { @@ -392,7 +400,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: Legacy.Request, + request: RequestWithLoginAttempt, { refreshToken }: ProviderState ) { this.debug('Trying to refresh access token.'); @@ -469,7 +477,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Tries to start SAML handshake and eventually receive a token. * @param request Request instance. */ - private async authenticateViaHandshake(request: Legacy.Request) { + private async authenticateViaHandshake(request: RequestWithLoginAttempt) { this.debug('Trying to initiate SAML handshake.'); // If client can't handle redirect response, we shouldn't initiate SAML handshake. diff --git a/x-pack/plugins/security/server/lib/authentication/providers/token.ts b/x-pack/plugins/security/server/lib/authentication/providers/token.ts index 2cdc893204cb8f..93b79ac711b2f1 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/token.ts @@ -9,8 +9,7 @@ import { canRedirectRequest } from '../../can_redirect_request'; import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { LoginAttempt } from '../login_attempt'; -import { BaseAuthenticationProvider } from './base'; +import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base'; /** * The state supported by the provider. @@ -29,10 +28,6 @@ interface ProviderState { refreshToken?: string; } -type RequestWithLoginAttempt = Legacy.Request & { - loginAttempt: () => LoginAttempt; -}; - /** * If request with access token fails with `401 Unauthorized` then this token is no * longer valid and we should try to refresh it. Another use case that we should @@ -160,7 +155,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * forward to Elasticsearch backend. * @param request Request instance. */ - private async authenticateViaHeader(request: Legacy.Request) { + private async authenticateViaHeader(request: RequestWithLoginAttempt) { this.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; @@ -252,7 +247,10 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) { + private async authenticateViaState( + request: RequestWithLoginAttempt, + { accessToken }: ProviderState + ) { this.debug('Trying to authenticate via state.'); if (!accessToken) { @@ -289,7 +287,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken( - request: Legacy.Request, + request: RequestWithLoginAttempt, { refreshToken }: ProviderState ) { this.debug('Trying to refresh access token.'); @@ -357,7 +355,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * Constructs login page URL using current url path as `next` query string parameter. * @param request Request instance. */ - private getLoginPageURL(request: Legacy.Request) { + private getLoginPageURL(request: RequestWithLoginAttempt) { const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`); return `${this.options.basePath}/login?next=${nextURL}`; } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 19adce3f304772..fe46b447c9090a 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -12,6 +12,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/plugin_api_integration/config.js'), + require.resolve('../test/kerberos_api_integration/config'), require.resolve('../test/saml_api_integration/config.js'), require.resolve('../test/token_api_integration/config.js'), // require.resolve('../test/oidc_api_integration/config.js'), diff --git a/x-pack/test/kerberos_api_integration/apis/index.ts b/x-pack/test/kerberos_api_integration/apis/index.ts new file mode 100644 index 00000000000000..5905edfcf59a19 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/apis/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaFunctionalTestDefaultProviders } from '../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('apis Kerberos', function() { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./security')); + }); +} diff --git a/x-pack/test/kerberos_api_integration/apis/security/index.ts b/x-pack/test/kerberos_api_integration/apis/security/index.ts new file mode 100644 index 00000000000000..3442d4ead16564 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/apis/security/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('security', () => { + loadTestFile(require.resolve('./kerberos_login')); + }); +} diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts new file mode 100644 index 00000000000000..7610314dbadba2 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -0,0 +1,375 @@ +/* + * 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 request, { Cookie } from 'request'; +import { delay } from 'bluebird'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: KibanaFunctionalTestDefaultProviders) { + const spnegoToken = + 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; + const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); + + function checkCookieIsSet(cookie: Cookie) { + expect(cookie.value).to.not.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(null); + } + + function checkCookieIsCleared(cookie: Cookie) { + expect(cookie.value).to.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(0); + } + + describe('Kerberos authentication', () => { + before(async () => { + // HACK: remove as soon as we have a solution for https://github.com/elastic/elasticsearch/issues/41943. + await getService('esSupertest') + .post('/_security/role/krb5-user') + .send({ cluster: ['cluster:admin/xpack/security/token/create'] }) + .expect(200); + + await getService('esSupertest') + .post('/_security/role_mapping/krb5') + .send({ + roles: ['krb5-user'], + enabled: true, + rules: { field: { 'realm.name': 'kerb1' } }, + }) + .expect(200); + }); + + it('should reject API requests if client is not authenticated', async () => { + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .expect(401); + }); + + it('does not prevent basic login', async () => { + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + const response = await supertest + .post('/api/security/v1/login') + .set('kbn-xsrf', 'xxx') + .send({ username, password }) + .expect(204); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const cookie = request.cookie(cookies[0])!; + checkCookieIsSet(cookie); + + const { body: user } = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', cookie.cookieString()) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); + }); + + describe('initiating SPNEGO', () => { + it('non-AJAX requests should properly initiate SPNEGO', async () => { + const spnegoResponse = await supertest.get('/abc/xyz/spnego?one=two three').expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + + it('AJAX requests should properly initiate SPNEGO', async () => { + const ajaxResponse = await supertest + .get('/abc/xyz/spnego?one=two three') + .set('kbn-xsrf', 'xxx') + .expect(401); + + expect(ajaxResponse.headers['set-cookie']).to.be(undefined); + expect(ajaxResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + }); + + describe('finishing SPNEGO', () => { + it('should properly set cookie and authenticate user', async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200, { + username: 'tester@TEST.ELASTIC.CO', + roles: ['krb5-user'], + scope: [], + full_name: null, + email: null, + metadata: { + kerberos_user_principal_name: 'tester@TEST.ELASTIC.CO', + kerberos_realm: 'TEST.ELASTIC.CO', + }, + enabled: true, + authentication_realm: { name: 'kerb1', type: 'kerberos' }, + lookup_realm: { name: 'kerb1', type: 'kerberos' }, + }); + }); + + it('should fail if SPNEGO token is rejected', async () => { + const spnegoResponse = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${Buffer.from('Hello').toString('base64')}`) + .expect(401); + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be(undefined); + }); + }); + + describe('API access with active session', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + }); + + it('should extend cookie on every successful non-system API call', async () => { + const apiResponseOne = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseOne.headers['set-cookie']).to.not.be(undefined); + const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0])!; + + checkCookieIsSet(sessionCookieOne); + expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); + + const apiResponseTwo = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseTwo.headers['set-cookie']).to.not.be(undefined); + const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0])!; + + checkCookieIsSet(sessionCookieTwo); + expect(sessionCookieTwo.value).to.not.equal(sessionCookieOne.value); + }); + + it('should not extend cookie for system API calls', async () => { + const systemAPIResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('kbn-system-api', 'true') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(systemAPIResponse.headers['set-cookie']).to.be(undefined); + }); + + it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Authorization', 'Bearer AbCdEf') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('logging out', () => { + it('should redirect to `logged_out` page after successful logout', async () => { + // First authenticate user to retrieve session cookie. + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + let cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // And then log user out. + const logoutResponse = await supertest + .get('/api/security/v1/logout') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + cookies = logoutResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(logoutResponse.headers.location).to.be('/logged_out'); + + // Token that was stored in the previous cookie should be invalidated as well and old + // session cookie should not allow API access. + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + // If Kibana detects cookie with invalid token it tries to clear it. + cookies = apiResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(apiResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + + it('should redirect to home page if session cookie is not provided', async () => { + const logoutResponse = await supertest.get('/api/security/v1/logout').expect(302); + + expect(logoutResponse.headers['set-cookie']).to.be(undefined); + expect(logoutResponse.headers.location).to.be('/'); + }); + }); + + describe('API access with expired access token.', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + }); + + it('AJAX call should initiate SPNEGO and clear existing cookie', async function() { + this.timeout(40000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await delay(20000); + + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + const cookies = apiResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(apiResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + + it('non-AJAX call should initiate SPNEGO and clear existing cookie', async function() { + this.timeout(40000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await delay(20000); + + const nonAjaxResponse = await supertest + .get('/') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + const cookies = nonAjaxResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(nonAjaxResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + }); + + describe('API access with missing access token document.', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // Let's delete tokens from `.security-tokens` index directly to simulate the case when + // Elasticsearch automatically removes access token document from the index after some + // period of time. + const esResponse = await getService('es').deleteByQuery({ + index: '.security-tokens', + q: 'doc_type:token', + refresh: true, + }); + expect(esResponse) + .to.have.property('deleted') + .greaterThan(0); + }); + + it('AJAX call should initiate SPNEGO and clear existing cookie', async function() { + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + const cookies = apiResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(apiResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + + it('non-AJAX call should initiate SPNEGO and clear existing cookie', async function() { + const nonAjaxResponse = await supertest + .get('/') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + const cookies = nonAjaxResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(nonAjaxResponse.headers['www-authenticate']).to.be('Negotiate'); + }); + }); + }); +} diff --git a/x-pack/test/kerberos_api_integration/config.ts b/x-pack/test/kerberos_api_integration/config.ts new file mode 100644 index 00000000000000..4141803ce011b5 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/config.ts @@ -0,0 +1,58 @@ +/* + * 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 { resolve } from 'path'; +import { KibanaFunctionalTestDefaultProviders } from '../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default async function({ readConfigFile }: KibanaFunctionalTestDefaultProviders) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + + const kerberosKeytabPath = resolve( + __dirname, + '../../test/kerberos_api_integration/fixtures/krb5.keytab' + ); + const kerberosConfigPath = resolve( + __dirname, + '../../test/kerberos_api_integration/fixtures/krb5.conf' + ); + + return { + testFiles: [require.resolve('./apis')], + servers: xPackAPITestsConfig.get('servers'), + services: { + es: xPackAPITestsConfig.get('services.es'), + esSupertest: xPackAPITestsConfig.get('services.esSupertest'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + junit: { + reportName: 'X-Pack Kerberos API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + `xpack.security.authc.realms.kerberos.kerb1.keytab.path=${kerberosKeytabPath}`, + ], + serverEnvVars: { + // We're going to use the same TGT multiple times and during a short period of time, so we + // have to disable replay cache so that ES doesn't complain about that. + ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, + }, + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--xpack.security.authProviders=${JSON.stringify(['kerberos', 'basic'])}`, + ], + }, + }; +} diff --git a/x-pack/test/kerberos_api_integration/fixtures/README.md b/x-pack/test/kerberos_api_integration/fixtures/README.md new file mode 100644 index 00000000000000..0234b8bd560b32 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/fixtures/README.md @@ -0,0 +1,10 @@ +# Kerberos Fixtures + +Kerberos fixtures are created with the following principals: + +* tester@TEST.ELASTIC.CO (password is `changeme`) +* host/kerberos.test.elastic.co@TEST.ELASTIC.CO +* HTTP/localhost@TEST.ELASTIC.CO + +The SPNEGO token used in tests is generated for for `tester@TEST.ELASTIC.CO`. We can re-use it multiple times because we +disable replay cache (`-Dsun.security.krb5.rcache=none`) and set max possible `clockskew` in `krb5.conf`. \ No newline at end of file diff --git a/x-pack/test/kerberos_api_integration/fixtures/krb5.conf b/x-pack/test/kerberos_api_integration/fixtures/krb5.conf new file mode 100755 index 00000000000000..1bad4eef0277b2 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/fixtures/krb5.conf @@ -0,0 +1,11 @@ +[libdefaults] + default_realm = TEST.ELASTIC.CO + clockskew = 2147483647 + +[realms] + TEST.ELASTIC.CO = { + max_life = 2147483647s + } + +[domain_realm] + localhost = TEST.ELASTIC.CO diff --git a/x-pack/test/kerberos_api_integration/fixtures/krb5.keytab b/x-pack/test/kerberos_api_integration/fixtures/krb5.keytab new file mode 100755 index 00000000000000..93499602ece918 Binary files /dev/null and b/x-pack/test/kerberos_api_integration/fixtures/krb5.keytab differ diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.js b/x-pack/test/saml_api_integration/apis/security/saml_login.js index c8e37ad9a177c6..351bb4d162c634 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.js +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.js @@ -41,6 +41,27 @@ export default function ({ getService }) { .expect(401); }); + it('does not prevent basic login', async () => { + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + const response = await supertest + .post('/api/security/v1/login') + .set('kbn-xsrf', 'xxx') + .send({ username, password }) + .expect(204); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const { body: user } = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', request.cookie(cookies[0]).cookieString()) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); + }); + describe('initiating handshake', () => { it('should properly set cookie and redirect user', async () => { const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') diff --git a/x-pack/test/saml_api_integration/config.js b/x-pack/test/saml_api_integration/config.js index 4ac092d7f09d11..f211b087f0ed9a 100644 --- a/x-pack/test/saml_api_integration/config.js +++ b/x-pack/test/saml_api_integration/config.js @@ -47,7 +47,7 @@ export default async function ({ readConfigFile }) { ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), '--optimize.enabled=false', '--server.xsrf.whitelist=[\"/api/security/v1/saml\"]', - '--xpack.security.authProviders=[\"saml\"]', + `--xpack.security.authProviders=${JSON.stringify(['saml', 'basic'])}`, ], }, };