diff --git a/x-pack/plugins/security/server/authentication/authentication_service.mock.ts b/x-pack/plugins/security/server/authentication/authentication_service.mock.ts index 06884611f3873..9c67cf611eb44 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.mock.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.mock.ts @@ -5,17 +5,11 @@ */ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; -import type { - AuthenticationServiceSetup, - AuthenticationServiceStart, -} from './authentication_service'; +import type { AuthenticationServiceStart } from './authentication_service'; import { apiKeysMock } from './api_keys/api_keys.mock'; export const authenticationServiceMock = { - createSetup: (): jest.Mocked => ({ - getCurrentUser: jest.fn(), - }), createStart: (): DeeplyMockedKeys => ({ apiKeys: apiKeysMock.create(), login: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index 59771c5027012..942ddc202360b 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -25,11 +25,9 @@ import { sessionMock } from '../session_management/session.mock'; import type { AuthenticationHandler, AuthToolkit, - ILegacyClusterClient, KibanaRequest, Logger, LoggerFactory, - LegacyScopedClusterClient, HttpServiceSetup, HttpServiceStart, } from '../../../../../src/core/server'; @@ -46,47 +44,17 @@ describe('AuthenticationService', () => { let service: AuthenticationService; let logger: jest.Mocked; let mockSetupAuthenticationParams: { - legacyAuditLogger: jest.Mocked; - audit: jest.Mocked; - config: ConfigType; - loggers: LoggerFactory; http: jest.Mocked; - clusterClient: jest.Mocked; license: jest.Mocked; - getFeatureUsageService: () => jest.Mocked; - session: jest.Mocked>; }; - let mockScopedClusterClient: jest.Mocked>; beforeEach(() => { logger = loggingSystemMock.createLogger(); mockSetupAuthenticationParams = { - legacyAuditLogger: securityAuditLoggerMock.create(), - audit: auditServiceMock.create(), http: coreMock.createSetup().http, - config: createConfig( - ConfigSchema.validate({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }), - loggingSystemMock.create().get(), - { isTLSEnabled: false } - ), - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), license: licenseMock.create(), - loggers: loggingSystemMock.create(), - getFeatureUsageService: jest - .fn() - .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), - session: sessionMock.create(), }; - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); - service = new AuthenticationService(logger); }); @@ -101,6 +69,42 @@ describe('AuthenticationService', () => { expect.any(Function) ); }); + }); + + describe('#start()', () => { + let mockStartAuthenticationParams: { + legacyAuditLogger: jest.Mocked; + audit: jest.Mocked; + config: ConfigType; + loggers: LoggerFactory; + http: jest.Mocked; + clusterClient: ReturnType; + featureUsageService: jest.Mocked; + session: jest.Mocked>; + }; + beforeEach(() => { + const coreStart = coreMock.createStart(); + mockStartAuthenticationParams = { + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ), + http: coreStart.http, + clusterClient: elasticsearchServiceMock.createClusterClient(), + loggers: loggingSystemMock.create(), + featureUsageService: securityFeatureUsageServiceMock.createStartContract(), + session: sessionMock.create(), + }; + + service.setup(mockSetupAuthenticationParams); + }); describe('authentication handler', () => { let authHandler: AuthenticationHandler; @@ -109,12 +113,7 @@ describe('AuthenticationService', () => { beforeEach(() => { mockAuthToolkit = httpServiceMock.createAuthToolkit(); - service.setup(mockSetupAuthenticationParams); - - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( - expect.any(Function) - ); + service.start(mockStartAuthenticationParams); authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] @@ -298,63 +297,7 @@ describe('AuthenticationService', () => { describe('getCurrentUser()', () => { let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null; beforeEach(async () => { - getCurrentUser = service.setup(mockSetupAuthenticationParams).getCurrentUser; - }); - - it('returns `null` if Security is disabled', () => { - mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); - - expect(getCurrentUser(httpServerMock.createKibanaRequest())).toBe(null); - }); - - it('returns user from the auth state.', () => { - const mockUser = mockAuthenticatedUser(); - - const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; - mockAuthGet.mockReturnValue({ state: mockUser }); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(getCurrentUser(mockRequest)).toBe(mockUser); - expect(mockAuthGet).toHaveBeenCalledTimes(1); - expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); - }); - - it('returns null if auth state is not available.', () => { - const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; - mockAuthGet.mockReturnValue({}); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(getCurrentUser(mockRequest)).toBeNull(); - expect(mockAuthGet).toHaveBeenCalledTimes(1); - expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); - }); - }); - }); - - describe('#start()', () => { - let mockStartAuthenticationParams: { - http: jest.Mocked; - clusterClient: ReturnType; - }; - beforeEach(() => { - const coreStart = coreMock.createStart(); - mockStartAuthenticationParams = { - http: coreStart.http, - clusterClient: elasticsearchServiceMock.createClusterClient(), - }; - service.setup(mockSetupAuthenticationParams); - }); - - describe('getCurrentUser()', () => { - let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null; - beforeEach(async () => { - getCurrentUser = (await service.start(mockStartAuthenticationParams)).getCurrentUser; - }); - - it('returns `null` if Security is disabled', () => { - mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); - - expect(getCurrentUser(httpServerMock.createKibanaRequest())).toBe(null); + getCurrentUser = service.start(mockStartAuthenticationParams).getCurrentUser; }); it('returns user from the auth state.', () => { diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index e435ae43f3bf3..3ab92d0bd211f 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -11,7 +11,6 @@ import type { Logger, HttpServiceSetup, IClusterClient, - ILegacyClusterClient, HttpServiceStart, } from '../../../../../src/core/server'; import type { SecurityLicense } from '../../common/licensing'; @@ -27,27 +26,19 @@ import { APIKeys } from './api_keys'; import { Authenticator, ProviderLoginAttempt } from './authenticator'; interface AuthenticationServiceSetupParams { - legacyAuditLogger: SecurityAuditLogger; - audit: AuditServiceSetup; - getFeatureUsageService: () => SecurityFeatureUsageServiceStart; - http: HttpServiceSetup; - clusterClient: ILegacyClusterClient; - config: ConfigType; + http: Pick; license: SecurityLicense; - loggers: LoggerFactory; - session: PublicMethodsOf; } interface AuthenticationServiceStartParams { - http: HttpServiceStart; + http: Pick; + config: ConfigType; clusterClient: IClusterClient; -} - -export interface AuthenticationServiceSetup { - /** - * @deprecated use `getCurrentUser` from the start contract instead - */ - getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; + legacyAuditLogger: SecurityAuditLogger; + audit: AuditServiceSetup; + featureUsageService: SecurityFeatureUsageServiceStart; + session: PublicMethodsOf; + loggers: LoggerFactory; } export interface AuthenticationServiceStart { @@ -67,44 +58,13 @@ export interface AuthenticationServiceStart { export class AuthenticationService { private license!: SecurityLicense; - private authenticator!: Authenticator; + private authenticator?: Authenticator; constructor(private readonly logger: Logger) {} - setup({ - legacyAuditLogger: auditLogger, - audit, - getFeatureUsageService, - http, - clusterClient, - config, - license, - loggers, - session, - }: AuthenticationServiceSetupParams): AuthenticationServiceSetup { + setup({ http, license }: AuthenticationServiceSetupParams) { this.license = license; - const getCurrentUser = (request: KibanaRequest) => { - if (!license.isEnabled()) { - return null; - } - - return http.auth.get(request).state ?? null; - }; - - this.authenticator = new Authenticator({ - legacyAuditLogger: auditLogger, - audit, - loggers, - clusterClient, - basePath: http.basePath, - config: { authc: config.authc }, - getCurrentUser, - getFeatureUsageService, - license, - session, - }); - http.registerAuth(async (request, response, t) => { if (!license.isLicenseAvailable()) { this.logger.error('License is not available, authentication is not possible.'); @@ -123,6 +83,15 @@ export class AuthenticationService { return t.authenticated(); } + if (!this.authenticator) { + this.logger.error('Authentication sub-system is not fully initialized yet.'); + return response.customError({ + body: 'Authentication sub-system is not fully initialized yet.', + statusCode: 503, + headers: { 'Retry-After': '30' }, + }); + } + let authenticationResult; try { authenticationResult = await this.authenticator.authenticate(request); @@ -174,19 +143,40 @@ export class AuthenticationService { }); this.logger.debug('Successfully registered core authentication handler.'); - - return { - getCurrentUser, - }; } - start({ clusterClient, http }: AuthenticationServiceStartParams): AuthenticationServiceStart { + start({ + audit, + config, + clusterClient, + featureUsageService, + http, + legacyAuditLogger, + loggers, + session, + }: AuthenticationServiceStartParams): AuthenticationServiceStart { const apiKeys = new APIKeys({ clusterClient, logger: this.logger.get('api-key'), license: this.license, }); + const getCurrentUser = (request: KibanaRequest) => + http.auth.get(request).state ?? null; + + this.authenticator = new Authenticator({ + legacyAuditLogger, + audit, + loggers, + clusterClient, + basePath: http.basePath, + config: { authc: config.authc }, + getCurrentUser, + featureUsageService, + license: this.license, + session, + }); + return { apiKeys: { areAPIKeysEnabled: apiKeys.areAPIKeysEnabled.bind(apiKeys), @@ -206,12 +196,7 @@ export class AuthenticationService { * Retrieves currently authenticated user associated with the specified request. * @param request */ - getCurrentUser: (request: KibanaRequest) => { - if (!this.license.isEnabled()) { - return null; - } - return http.auth.get(request).state ?? null; - }, + getCurrentUser, }; } } diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 3d3946fde9f34..08d671d64179a 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -43,7 +43,7 @@ function getMockOptions({ legacyAuditLogger: securityAuditLoggerMock.create(), audit: auditServiceMock.create(), getCurrentUser: jest.fn(), - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, license: licenseMock.create(), loggers: loggingSystemMock.create(), @@ -53,9 +53,7 @@ function getMockOptions({ { isTLSEnabled: false } ), session: sessionMock.create(), - getFeatureUsageService: jest - .fn() - .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), + featureUsageService: securityFeatureUsageServiceMock.createStartContract(), }; } @@ -1880,9 +1878,7 @@ describe('Authenticator', () => { ); expect(mockOptions.session.update).not.toHaveBeenCalled(); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).not.toHaveBeenCalled(); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).not.toHaveBeenCalled(); }); it('fails if cannot retrieve user session', async () => { @@ -1895,12 +1891,10 @@ describe('Authenticator', () => { ); expect(mockOptions.session.update).not.toHaveBeenCalled(); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).not.toHaveBeenCalled(); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).not.toHaveBeenCalled(); }); - it('fails if license doesn allow access agreement acknowledgement', async () => { + it('fails if license does not allow access agreement acknowledgement', async () => { mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: false, } as SecurityLicenseFeatures); @@ -1912,9 +1906,7 @@ describe('Authenticator', () => { ); expect(mockOptions.session.update).not.toHaveBeenCalled(); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).not.toHaveBeenCalled(); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).not.toHaveBeenCalled(); }); it('properly acknowledges access agreement for the authenticated user', async () => { @@ -1936,9 +1928,9 @@ describe('Authenticator', () => { } ); - expect( - mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage - ).toHaveBeenCalledTimes(1); + expect(mockOptions.featureUsageService.recordPreAccessAgreementUsage).toHaveBeenCalledTimes( + 1 + ); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 85215ebf46fb4..af492bf247726 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -7,8 +7,8 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { KibanaRequest, LoggerFactory, - ILegacyClusterClient, IBasePath, + IClusterClient, } from '../../../../../src/core/server'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, @@ -68,13 +68,13 @@ export interface ProviderLoginAttempt { export interface AuthenticatorOptions { legacyAuditLogger: SecurityAuditLogger; audit: AuditServiceSetup; - getFeatureUsageService: () => SecurityFeatureUsageServiceStart; + featureUsageService: SecurityFeatureUsageServiceStart; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; config: Pick; basePath: IBasePath; license: SecurityLicense; loggers: LoggerFactory; - clusterClient: ILegacyClusterClient; + clusterClient: IClusterClient; session: PublicMethodsOf; } @@ -201,7 +201,7 @@ export class Authenticator { client: this.options.clusterClient, basePath: this.options.basePath, tokens: new Tokens({ - client: this.options.clusterClient, + client: this.options.clusterClient.asInternalUser, logger: this.options.loggers.get('tokens'), }), }; @@ -448,7 +448,7 @@ export class Authenticator { existingSessionValue.provider ); - this.options.getFeatureUsageService().recordPreAccessAgreementUsage(); + this.options.featureUsageService.recordPreAccessAgreementUsage(); } /** diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index c87a02c9545c1..e745e1c5717b3 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -5,11 +5,7 @@ */ export { canRedirectRequest } from './can_redirect_request'; -export { - AuthenticationService, - AuthenticationServiceSetup, - AuthenticationServiceStart, -} from './authentication_service'; +export { AuthenticationService, AuthenticationServiceStart } from './authentication_service'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; export { diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts index 9674181e18750..a2db413319546 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { errors } from '@elastic/elasticsearch'; + import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { mockAuthenticationProviderOptions } from './base.mock'; -import { ILegacyClusterClient, ScopeableRequest } from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { @@ -18,15 +21,14 @@ import { import { AnonymousAuthenticationProvider } from './anonymous'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } enum CredentialsType { @@ -75,8 +77,10 @@ describe('AnonymousAuthenticationProvider', () => { describe('`login` method', () => { it('succeeds if credentials are valid, and creates session and authHeaders', async () => { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect( @@ -92,10 +96,13 @@ describe('AnonymousAuthenticationProvider', () => { it('fails if user cannot be retrieved during login attempt', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - - const authenticationError = new Error('Some error'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.login(request)).resolves.toEqual( @@ -155,8 +162,10 @@ describe('AnonymousAuthenticationProvider', () => { it('succeeds for non-AJAX requests if state is available.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, {})).resolves.toEqual( @@ -169,8 +178,10 @@ describe('AnonymousAuthenticationProvider', () => { it('succeeds for AJAX requests if state is available.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, {})).resolves.toEqual( @@ -185,8 +196,10 @@ describe('AnonymousAuthenticationProvider', () => { it('non-AJAX requests can start a new session.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request)).resolves.toEqual( @@ -199,9 +212,13 @@ describe('AnonymousAuthenticationProvider', () => { it('fails if credentials are not valid.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const authenticationError = new Error('Forbidden'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request)).resolves.toEqual( @@ -225,8 +242,10 @@ describe('AnonymousAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, {})).resolves.toEqual( diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts index 1585b0592b356..249b4adea7bba 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, LegacyElasticsearchErrorHelpers } from '../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; +import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -213,7 +214,7 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider // Create session only if it doesn't exist yet, otherwise keep it unchanged. return AuthenticationResult.succeeded(user, { authHeaders, state: state ? undefined : {} }); } catch (err) { - if (LegacyElasticsearchErrorHelpers.isNotAuthorizedError(err)) { + if (getErrorStatusCode(err) === 401) { if (!this.httpAuthorizationHeader) { this.logger.error( `Failed to authenticate anonymous request using Elasticsearch reserved anonymous user. Anonymous access may not be properly configured in Elasticsearch: ${err.message}` diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 47d961bc8faf8..3eea6f9aadadf 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -16,7 +16,7 @@ export type MockAuthenticationProviderOptions = ReturnType< export function mockAuthenticationProviderOptions(options?: { name: string }) { return { - client: elasticsearchServiceMock.createLegacyClusterClient(), + client: elasticsearchServiceMock.createClusterClient(), logger: loggingSystemMock.create().get(), basePath: httpServiceMock.createBasePath(), tokens: { refresh: jest.fn(), invalidate: jest.fn() }, diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index f1845617c87a4..73449cf1077fe 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -10,8 +10,8 @@ import { KibanaRequest, Logger, HttpServiceSetup, - ILegacyClusterClient, Headers, + IClusterClient, } from '../../../../../../src/core/server'; import type { AuthenticatedUser } from '../../../common/model'; import type { AuthenticationInfo } from '../../elasticsearch'; @@ -25,7 +25,7 @@ import { Tokens } from '../tokens'; export interface AuthenticationProviderOptions { name: string; basePath: HttpServiceSetup['basePath']; - client: ILegacyClusterClient; + client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; urls: { @@ -111,9 +111,11 @@ export abstract class BaseAuthenticationProvider { */ protected async getUser(request: KibanaRequest, authHeaders: Headers = {}) { return this.authenticationInfoToAuthenticatedUser( - await this.options.client - .asScoped({ headers: { ...request.headers, ...authHeaders } }) - .callAsCurrentUser('shield.authenticate') + ( + await this.options.client + .asScoped({ headers: { ...request.headers, ...authHeaders } }) + .asCurrentUser.security.authenticate() + ).body ); } diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 4f93e2327da06..e7cf3d95b0827 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { errors } from '@elastic/elasticsearch'; + import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { mockAuthenticationProviderOptions } from './base.mock'; -import { ILegacyClusterClient, ScopeableRequest } from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { BasicAuthenticationProvider } from './basic'; @@ -18,15 +21,14 @@ function generateAuthorizationHeader(username: string, password: string) { } function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('BasicAuthenticationProvider', () => { @@ -45,8 +47,10 @@ describe('BasicAuthenticationProvider', () => { const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect( @@ -66,9 +70,13 @@ describe('BasicAuthenticationProvider', () => { const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); - const authenticationError = new Error('Some error'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.login(request, credentials)).resolves.toEqual( @@ -149,8 +157,10 @@ describe('BasicAuthenticationProvider', () => { const user = mockAuthenticatedUser(); const authorization = generateAuthorizationHeader('user', 'password'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, { authorization })).resolves.toEqual( @@ -164,9 +174,13 @@ describe('BasicAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = generateAuthorizationHeader('user', 'password'); - const authenticationError = new Error('Forbidden'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, { authorization })).resolves.toEqual( diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index 512a8ead2c32b..b8a2a110d45b0 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -4,29 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { errors } from '@elastic/elasticsearch'; + import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { HTTPAuthenticationProvider } from './http'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('HTTPAuthenticationProvider', () => { @@ -58,7 +56,6 @@ describe('HTTPAuthenticationProvider', () => { await expect(provider.login()).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); }); @@ -73,7 +70,6 @@ describe('HTTPAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('does not handle authentication for requests with empty scheme in `authorization` header.', async () => { @@ -88,7 +84,6 @@ describe('HTTPAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('does not handle authentication via `authorization` header if scheme is not supported.', async () => { @@ -112,7 +107,6 @@ describe('HTTPAuthenticationProvider', () => { } expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('succeeds if authentication via `authorization` header with supported scheme succeeds.', async () => { @@ -126,8 +120,10 @@ describe('HTTPAuthenticationProvider', () => { ]) { const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.asScoped.mockClear(); @@ -149,7 +145,7 @@ describe('HTTPAuthenticationProvider', () => { }); it('fails if authentication via `authorization` header with supported scheme fails.', async () => { - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const failureReason = new errors.ResponseError(securityMock.createApiResponse({ body: {} })); for (const { schemes, header } of [ { schemes: ['basic'], header: 'Basic xxx' }, { schemes: ['bearer'], header: 'Bearer xxx' }, @@ -159,8 +155,10 @@ describe('HTTPAuthenticationProvider', () => { ]) { const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + failureReason + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.asScoped.mockClear(); @@ -188,7 +186,6 @@ describe('HTTPAuthenticationProvider', () => { await expect(provider.logout()).resolves.toEqual(DeauthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index d368bf90cf360..f8b7b42d1845b 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -5,32 +5,27 @@ */ import Boom from '@hapi/boom'; -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - KibanaRequest, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { KibanaRequest, ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { KerberosAuthenticationProvider } from './kerberos'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('KerberosAuthenticationProvider', () => { @@ -47,8 +42,10 @@ describe('KerberosAuthenticationProvider', () => { it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: {} }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); @@ -61,9 +58,9 @@ describe('KerberosAuthenticationProvider', () => { it('does not handle requests if backend does not support Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -77,17 +74,18 @@ describe('KerberosAuthenticationProvider', () => { it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, }) ); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(operation(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); @@ -100,9 +98,12 @@ describe('KerberosAuthenticationProvider', () => { it('fails if request authentication is failed with non-401 error.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const failureReason = new errors.ServiceUnavailable(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.NoLivingConnectionsError( + 'Unavailable', + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -118,11 +119,15 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - authentication: user, - }); + mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + authentication: user, + }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -135,7 +140,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -148,12 +153,16 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - kerberos_authentication_response_token: 'response-token', - authentication: user, - }); + mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + kerberos_authentication_response_token: 'response-token', + authentication: user, + }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -167,7 +176,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -179,12 +188,13 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, body: { error: { header: { 'WWW-Authenticate': 'Negotiate response-token' } } }, }) ); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { @@ -192,7 +202,7 @@ describe('KerberosAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -204,12 +214,13 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, }) ); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { @@ -217,7 +228,7 @@ describe('KerberosAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -229,12 +240,14 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); @@ -259,7 +272,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -277,7 +290,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -285,14 +298,15 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) + AuthenticationResult.failed(Boom.unauthorized()) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -306,7 +320,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); }); it('does not start SPNEGO for Ajax requests.', async () => { @@ -316,7 +330,7 @@ describe('KerberosAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { @@ -328,8 +342,10 @@ describe('KerberosAuthenticationProvider', () => { }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -349,9 +365,9 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -384,9 +400,11 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - const failureReason = new errors.InternalServerError('Token is not valid!'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -396,23 +414,22 @@ describe('KerberosAuthenticationProvider', () => { expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Bearer ${tokenPair.accessToken}` }, }); - - expect(mockScopedClusterClient.callAsInternalUser).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => { - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { - body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, - }) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 401, + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) ); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.mockResolvedValue(null); const nonAjaxRequest = httpServerMock.createKibanaRequest(); @@ -421,7 +438,7 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; await expect(provider.authenticate(nonAjaxRequest, nonAjaxTokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); @@ -432,7 +449,7 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'ajax-some-valid-refresh-token', }; await expect(provider.authenticate(ajaxRequest, ajaxTokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); @@ -445,7 +462,7 @@ describe('KerberosAuthenticationProvider', () => { await expect( provider.authenticate(optionalAuthRequest, optionalAuthTokenPair) ).resolves.toEqual( - AuthenticationResult.failed(failureReason, { + AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) ); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index b7abed979164e..a02f6a8dfb945 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -5,12 +5,10 @@ */ import Boom from '@hapi/boom'; -import { - LegacyElasticsearchError, - LegacyElasticsearchErrorHelpers, - KibanaRequest, -} from '../../../../../../src/core/server'; +import { errors } from '@elastic/elasticsearch'; +import type { KibanaRequest } from '../../../../../../src/core/server'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { HTTPAuthorizationHeader } from '../http_authentication'; @@ -153,16 +151,21 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { authentication: AuthenticationInfo; }; try { - tokens = await this.options.client.callAsInternalUser('shield.getAccessToken', { - body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, - }); + tokens = ( + await this.options.client.asInternalUser.security.getToken({ + body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, + }) + ).body; } catch (err) { - this.logger.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); + this.logger.debug( + `Failed to exchange SPNEGO token for an access token: ${getDetailedErrorMessage(err)}` + ); // Check if SPNEGO context wasn't established and we have a response token to return to the client. - const challenge = LegacyElasticsearchErrorHelpers.isNotAuthorizedError(err) - ? this.getNegotiateChallenge(err) - : undefined; + const challenge = + getErrorStatusCode(err) === 401 && err instanceof errors.ResponseError + ? this.getNegotiateChallenge(err) + : undefined; if (!challenge) { return AuthenticationResult.failed(err); } @@ -292,7 +295,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate request via SPNEGO.'); // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. - let elasticsearchError: LegacyElasticsearchError; + let elasticsearchError: errors.ResponseError; try { await this.getUser(request, { // We should send a fake SPNEGO token to Elasticsearch to make sure Kerberos realm is included @@ -306,7 +309,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } catch (err) { // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch // session cookie in this case. - if (!LegacyElasticsearchErrorHelpers.isNotAuthorizedError(err)) { + if (getErrorStatusCode(err) !== 401 || !(err instanceof errors.ResponseError)) { return AuthenticationResult.failed(err); } @@ -332,11 +335,14 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * Extracts `Negotiate` challenge from the list of challenges returned with Elasticsearch error if any. * @param error Error to extract challenges from. */ - private getNegotiateChallenge(error: LegacyElasticsearchError) { + private getNegotiateChallenge(error: errors.ResponseError) { + // We extract headers from the original Elasticsearch error and not from the top-level `headers` + // property of the Elasticsearch client error since client merges multiple `WWW-Authenticate` + // headers into one using comma as a separator. That makes it hard to correctly parse the header + // since `WWW-Authenticate` values can also include commas. const challenges = ([] as string[]).concat( - (error.output.headers as { [key: string]: string })[WWWAuthenticateHeaderName] + error.body?.error?.header?.[WWWAuthenticateHeaderName] || [] ); - const negotiateChallenge = challenges.find((challenge) => challenge.toLowerCase().startsWith('negotiate') ); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 9988ddd99c395..8037b067852d8 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -5,16 +5,14 @@ */ import Boom from '@hapi/boom'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - KibanaRequest, - ILegacyScopedClusterClient, -} from '../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { OIDCAuthenticationProvider, OIDCLogin, ProviderLoginAttempt } from './oidc'; @@ -24,19 +22,18 @@ describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; let mockUser: AuthenticatedUser; - let mockScopedClusterClient: jest.Mocked; + let mockScopedClusterClient: ReturnType< + typeof elasticsearchServiceMock.createScopedClusterClient + >; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' }); - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockUser = mockAuthenticatedUser({ authentication_provider: { type: 'oidc', name: 'oidc' } }); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { - if (method === 'shield.authenticate') { - return mockUser; - } - - throw new Error(`Unexpected call to ${method}!`); - }); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: mockUser }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); @@ -60,17 +57,21 @@ describe('OIDCAuthenticationProvider', () => { it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/security/oidc/callback' }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - 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' + - '&login_hint=loginhint', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + 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' + + '&login_hint=loginhint', + }, + }) + ); await expect( provider.login(request, { @@ -97,7 +98,10 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', body: { iss: 'theissuer', login_hint: 'loginhint' }, }); }); @@ -105,17 +109,21 @@ describe('OIDCAuthenticationProvider', () => { it('redirects user initiated login attempts to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - 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' + - '&login_hint=loginhint', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + 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' + + '&login_hint=loginhint', + }, + }) + ); await expect( provider.login(request, { @@ -141,7 +149,10 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', body: { realm: 'oidc1' }, }); }); @@ -149,8 +160,10 @@ describe('OIDCAuthenticationProvider', () => { it('fails if OpenID Connect authentication request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login(request, { @@ -159,8 +172,11 @@ describe('OIDCAuthenticationProvider', () => { }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', + body: { realm: 'oidc1' }, }); }); @@ -174,11 +190,15 @@ describe('OIDCAuthenticationProvider', () => { it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { const { request, attempt, expectedRedirectURI } = getMocks(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: mockUser, - access_token: 'some-token', - refresh_token: 'some-refresh-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + authentication: mockUser, + access_token: 'some-token', + refresh_token: 'some-refresh-token', + }, + }) + ); await expect( provider.login(request, attempt, { @@ -198,17 +218,17 @@ describe('OIDCAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.oidcAuthenticate', - { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: expectedRedirectURI, - realm: 'oidc1', - }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/authenticate', + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: expectedRedirectURI, + realm: 'oidc1', + }, + }); }); it('fails if authentication response is presented but session state does not contain the state parameter.', async () => { @@ -224,7 +244,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if authentication response is presented but session state does not contain redirect URL.', async () => { @@ -244,7 +264,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if session state is not presented.', async () => { @@ -258,16 +278,19 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if authentication response is not valid.', async () => { const { request, attempt, expectedRedirectURI } = getMocks(); - const failureReason = new Error( - 'Failed to exchange code for Id Token using the Token Endpoint.' + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 400, + body: { message: 'Failed to exchange code for Id Token using the Token Endpoint.' }, + }) ); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login(request, attempt, { @@ -278,17 +301,17 @@ describe('OIDCAuthenticationProvider', () => { }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.oidcAuthenticate', - { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: expectedRedirectURI, - realm: 'oidc1', - }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/authenticate', + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: expectedRedirectURI, + realm: 'oidc1', + }, + }); }); it('fails if realm from state is different from the realm provider is configured with.', async () => { @@ -302,7 +325,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); } @@ -353,11 +376,6 @@ describe('OIDCAuthenticationProvider', () => { it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - await expect(provider.authenticate(request, null)).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', @@ -365,7 +383,7 @@ describe('OIDCAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { @@ -425,8 +443,10 @@ describe('OIDCAuthenticationProvider', () => { }; const authorization = `Bearer ${tokenPair.accessToken}`; - const failureReason = new Error('Token is not valid!'); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 400, body: {} }) + ); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) @@ -441,8 +461,8 @@ describe('OIDCAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue({ @@ -475,8 +495,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); const refreshFailureReason = { @@ -502,8 +522,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -524,10 +544,9 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { @@ -535,8 +554,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -562,8 +581,8 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -604,7 +623,7 @@ describe('OIDCAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects to logged out view if state is `null` or does not include access token.', async () => { @@ -617,7 +636,7 @@ describe('OIDCAuthenticationProvider', () => { DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if OpenID Connect logout call fails.', async () => { @@ -625,15 +644,22 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 400, + body: { message: 'Realm is misconfigured!' }, + }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -643,14 +669,18 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -660,9 +690,11 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - redirect: 'http://fake-idp/logout&id_token_hint=thehint', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { redirect: 'http://fake-idp/logout&id_token_hint=thehint' }, + }) + ); await expect( provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) @@ -670,8 +702,10 @@ describe('OIDCAuthenticationProvider', () => { DeauthenticationResult.redirectTo('http://fake-idp/logout&id_token_hint=thehint') ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index c46ea37f144e9..b89267f44eeeb 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -248,14 +248,20 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { 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/oidc/authenticate`. - result = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', { - body: { - state: stateOIDCState, - nonce: stateNonce, - redirect_uri: authenticationResponseURI, - realm: this.realm, - }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + result = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/oidc/authenticate', + body: { + state: stateOIDCState, + nonce: stateNonce, + redirect_uri: authenticationResponseURI, + realm: this.realm, + }, + }) + ).body as any; } catch (err) { this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); return AuthenticationResult.failed(err); @@ -289,11 +295,15 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { 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/oidc/prepare`. - const { - state, - nonce, - redirect, - } = await this.options.client.callAsInternalUser('shield.oidcPrepare', { body: params }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { state, nonce, redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/oidc/prepare', + body: params, + }) + ).body as any; this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.'); return AuthenticationResult.redirectTo( @@ -407,18 +417,17 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (state?.accessToken) { try { - const logoutBody = { - body: { - token: state.accessToken, - refresh_token: state.refreshToken, - }, - }; // 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.callAsInternalUser( - 'shield.oidcLogout', - logoutBody - ); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/oidc/logout', + body: { token: state.accessToken, refresh_token: state.refreshToken }, + }) + ).body as any; this.logger.debug('User session has been successfully invalidated.'); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 88753f8dc2ab1..d98d6ca4fa071 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -10,18 +10,14 @@ jest.mock('tls'); import { Socket } from 'net'; import { PeerCertificate, TLSSocket } from 'tls'; import Boom from '@hapi/boom'; -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - KibanaRequest, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { KibanaRequest, ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { PKIAuthenticationProvider } from './pki'; @@ -87,15 +83,14 @@ function getMockSocket({ } function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('PKIAuthenticationProvider', () => { @@ -125,7 +120,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]', 'Authentication is not possible since peer certificate was not authorized: Error: mock authorization error.' @@ -139,7 +134,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( 'Peer certificate chain: []', 'Authentication is not possible due to missing peer certificate chain.' @@ -159,7 +154,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.3', cannot renegotiate connection.`, 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]', @@ -181,7 +176,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`, `Failed to renegotiate connection: Error: Oh no!.`, @@ -203,7 +198,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`, 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]', @@ -231,7 +226,7 @@ describe('PKIAuthenticationProvider', () => { await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expectDebugLogs( `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`, 'Self-signed certificate is detected in certificate chain', @@ -253,10 +248,11 @@ describe('PKIAuthenticationProvider', () => { mockGetPeerCertificate.mockReturnValueOnce(peerCertificate1); const request = httpServerMock.createKibanaRequest({ socket }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -268,8 +264,10 @@ describe('PKIAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -295,10 +293,11 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -310,8 +309,10 @@ describe('PKIAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -330,10 +331,11 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( @@ -345,8 +347,10 @@ describe('PKIAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, }); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); @@ -359,13 +363,17 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, }); @@ -390,7 +398,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -408,7 +416,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); }); @@ -421,7 +429,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('does not exchange peer certificate to access token for Ajax requests.', async () => { @@ -436,7 +444,7 @@ describe('PKIAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { @@ -490,10 +498,11 @@ describe('PKIAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ socket }); const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '3A:9A:C5:DD' }; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.succeeded( @@ -510,8 +519,10 @@ describe('PKIAuthenticationProvider', () => { accessToken: state.accessToken, }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -526,15 +537,16 @@ describe('PKIAuthenticationProvider', () => { it('gets a new access token even if existing token is expired.', async () => { const user = mockAuthenticatedUser({ authentication_provider: { type: 'pki', name: 'pki' } }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - authentication: user, - access_token: 'access-token', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { authentication: user, access_token: 'access-token' }, + }) + ); const nonAjaxRequest = httpServerMock.createKibanaRequest({ socket: getMockSocket({ @@ -589,8 +601,10 @@ describe('PKIAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(3); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(3); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:2A:7A:C2:DD:base64', @@ -598,7 +612,9 @@ describe('PKIAuthenticationProvider', () => { ], }, }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:3A:7A:C2:DD:base64', @@ -606,7 +622,9 @@ describe('PKIAuthenticationProvider', () => { ], }, }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/delegate_pki', body: { x509_certificate_chain: [ 'fingerprint:4A:7A:C2:DD:base64', @@ -625,9 +643,9 @@ describe('PKIAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ socket }); const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -635,7 +653,7 @@ describe('PKIAuthenticationProvider', () => { AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -647,8 +665,10 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( @@ -660,7 +680,7 @@ describe('PKIAuthenticationProvider', () => { expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer token' } }); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -671,9 +691,12 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); - const failureReason = new errors.ServiceUnavailable(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ConnectionError( + 'unavailable', + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 3171c5ff24abe..a5dc870bf9c84 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -272,9 +272,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { let result: { access_token: string; authentication: AuthenticationInfo }; try { - result = await this.options.client.callAsInternalUser('shield.delegatePKI', { - body: { x509_certificate_chain: certificateChain }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + result = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/delegate_pki', + body: { x509_certificate_chain: certificateChain }, + }) + ).body as any; } catch (err) { this.logger.debug( `Failed to exchange peer certificate chain to an access token: ${err.message}` @@ -298,7 +304,8 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Obtains the peer certificate chain. Starts from the leaf peer certificate and iterates up to the top-most available certificate + * Obtains the peer certificate chain as an ordered array of base64-encoded (Section 4 of RFC4648 - not base64url-encoded) + * DER PKIX certificate values. Starts from the leaf peer certificate and iterates up to the top-most available certificate * authority using `issuerCertificate` certificate property. THe iteration is stopped only when we detect circular reference * (root/self-signed certificate) or when `issuerCertificate` isn't available (null or empty object). Automatically attempts to * renegotiate the TLS connection once if the peer certificate chain is incomplete. diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 5cba017e4916b..48334358bb001 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -5,15 +5,13 @@ */ import Boom from '@hapi/boom'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyScopedClusterClient, -} from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; @@ -23,19 +21,17 @@ describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; let mockUser: AuthenticatedUser; - let mockScopedClusterClient: jest.Mocked; + let mockScopedClusterClient: ReturnType< + typeof elasticsearchServiceMock.createScopedClusterClient + >; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockUser = mockAuthenticatedUser({ authentication_provider: { type: 'saml', name: 'saml' } }); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { - if (method === 'shield.authenticate') { - return mockUser; - } - - throw new Error(`Unexpected call to ${method}!`); - }); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: mockUser }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); provider = new SAMLAuthenticationProvider(mockOptions, { @@ -61,11 +57,15 @@ describe('SAMLAuthenticationProvider', () => { it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + authentication: mockUser, + }, + }) + ); await expect( provider.login( @@ -88,20 +88,25 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('gets token and redirects user to the requested URL if SAML Response is valid ignoring Relay State.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'some-token', - refresh_token: 'some-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'some-token', + refresh_token: 'some-refresh-token', + authentication: mockUser, + }, + }) + ); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', @@ -132,10 +137,11 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => { @@ -153,7 +159,7 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if realm from state is different from the realm provider is configured with.', async () => { @@ -173,17 +179,21 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects to the default location if state contains empty redirect URL.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'user-initiated-login-token', - refresh_token: 'user-initiated-login-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'user-initiated-login-token', + refresh_token: 'user-initiated-login-refresh-token', + authentication: mockUser, + }, + }) + ); await expect( provider.login( @@ -202,20 +212,25 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('redirects to the default location if state contains empty redirect URL ignoring Relay State.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'user-initiated-login-token', - refresh_token: 'user-initiated-login-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'user-initiated-login-token', + refresh_token: 'user-initiated-login-refresh-token', + authentication: mockUser, + }, + }) + ); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', @@ -242,20 +257,25 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('redirects to the default location if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: 'idp-initiated-login-token', - refresh_token: 'idp-initiated-login-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: 'idp-initiated-login-token', + refresh_token: 'idp-initiated-login-refresh-token', + authentication: mockUser, + }, + }) + ); await expect( provider.login(request, { @@ -273,17 +293,20 @@ describe('SAMLAuthenticationProvider', () => { }) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('fails if SAML Response is rejected.', async () => { const request = httpServerMock.createKibanaRequest(); - const failureReason = new Error('SAML response is stale!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login( @@ -297,22 +320,27 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); describe('IdP initiated login', () => { beforeEach(() => { mockOptions.basePath.get.mockReturnValue(mockOptions.basePath.serverBasePath); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'valid-token', - refresh_token: 'valid-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'valid-token', + refresh_token: 'valid-refresh-token', + authentication: mockUser, + }, + }) + ); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', @@ -428,8 +456,10 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = 'Bearer some-valid-token'; - const failureReason = new Error('SAML response is invalid!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 503, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login( @@ -444,12 +474,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); }); it('fails if fails to invalidate existing access/refresh tokens.', async () => { @@ -461,12 +490,16 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - authentication: mockUser, - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + authentication: mockUser, + }, + }) + ); const failureReason = new Error('Failed to invalidate token!'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); @@ -480,12 +513,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -498,14 +530,20 @@ describe('SAMLAuthenticationProvider', () => { [ 'current session is valid', Promise.resolve( - mockAuthenticatedUser({ authentication_provider: { type: 'saml', name: 'saml' } }) + securityMock.createApiResponse({ + body: mockAuthenticatedUser({ + authentication_provider: { type: 'saml', name: 'saml' }, + }), + }) ), ], [ 'current session is is expired', - Promise.reject(LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())), + Promise.reject( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) + ), ], - ] as Array<[string, Promise]>) { + ] as Array<[string, any]>) { it(`redirects to the home page if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { @@ -516,19 +554,19 @@ describe('SAMLAuthenticationProvider', () => { const authorization = `Bearer ${state.accessToken}`; // The first call is made using tokens from existing session. - mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); - // The second call is made using new tokens. - mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(mockUser) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockImplementationOnce( + () => response + ); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + authentication: mockUser, + }, + }) ); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - authentication: mockUser, - }); - mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect( @@ -549,12 +587,11 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -573,13 +610,19 @@ describe('SAMLAuthenticationProvider', () => { const authorization = `Bearer ${state.accessToken}`; // The first call is made using tokens from existing session. - mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - authentication: mockUser, - }); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockImplementationOnce( + () => response + ); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + authentication: mockUser, + }, + }) + ); mockOptions.tokens.invalidate.mockResolvedValue(undefined); @@ -610,12 +653,11 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -641,16 +683,20 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects requests to the IdP remembering redirect URL with existing state.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }, + }) + ); await expect( provider.login( @@ -674,7 +720,9 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: 'test-realm' }, }); @@ -684,10 +732,14 @@ describe('SAMLAuthenticationProvider', () => { it('redirects requests to the IdP remembering redirect URL without state.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }, + }) + ); await expect( provider.login( @@ -711,7 +763,9 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: 'test-realm' }, }); @@ -721,8 +775,10 @@ describe('SAMLAuthenticationProvider', () => { it('fails if SAML request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.login( @@ -735,7 +791,9 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: 'test-realm' }, }); }); @@ -791,11 +849,6 @@ describe('SAMLAuthenticationProvider', () => { it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', @@ -803,7 +856,7 @@ describe('SAMLAuthenticationProvider', () => { ) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { @@ -833,11 +886,13 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - const failureReason = { statusCode: 500, message: 'Token is not valid!' }; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(failureReason as any) + AuthenticationResult.failed(failureReason) ); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); @@ -853,8 +908,8 @@ describe('SAMLAuthenticationProvider', () => { realm: 'test-realm', }; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue({ @@ -889,8 +944,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); const refreshFailureReason = { @@ -920,8 +975,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -949,8 +1004,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -976,8 +1031,8 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -994,7 +1049,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if realm from state is different from the realm provider is configured with.', async () => { @@ -1015,7 +1070,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects to logged out view if state is `null` or does not include access token.', async () => { @@ -1028,7 +1083,7 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('fails if SAML logout call fails.', async () => { @@ -1036,8 +1091,10 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( provider.logout(request, { @@ -1047,8 +1104,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1056,15 +1115,19 @@ describe('SAMLAuthenticationProvider', () => { 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.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect(provider.logout(request)).resolves.toEqual( DeauthenticationResult.failed(failureReason) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1074,7 +1137,9 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { @@ -1084,8 +1149,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1095,7 +1162,9 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: undefined } }) + ); await expect( provider.logout(request, { @@ -1105,8 +1174,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1118,7 +1189,9 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { @@ -1128,8 +1201,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/logout', body: { token: accessToken, refresh_token: refreshToken }, }); }); @@ -1137,7 +1212,9 @@ describe('SAMLAuthenticationProvider', () => { it('relies on SAML invalidate call even if access token is presented.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect( provider.logout(request, { @@ -1147,8 +1224,10 @@ describe('SAMLAuthenticationProvider', () => { }) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1156,14 +1235,18 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to `loggedOut` URL if `redirect` field in SAML invalidate response is null.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: null } }) + ); await expect(provider.logout(request)).resolves.toEqual( DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1171,14 +1254,18 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to `loggedOut` URL if `redirect` field in SAML invalidate response is not defined.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ body: { redirect: undefined } }) + ); await expect(provider.logout(request)).resolves.toEqual( DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/invalidate', body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1190,7 +1277,7 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { @@ -1198,9 +1285,11 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }, + }) + ); await expect( provider.logout(request, { @@ -1212,15 +1301,17 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); }); 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.mockResolvedValue({ - redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', - }); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }, + }) + ); await expect( provider.logout(request, { @@ -1232,7 +1323,7 @@ describe('SAMLAuthenticationProvider', () => { DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 34639a849d354..58792727de733 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -343,13 +343,19 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { 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/authenticate`. - result = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { - body: { - ids: !isIdPInitiatedLogin ? [stateRequestId] : [], - content: samlResponse, - realm: this.realm, - }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + result = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/authenticate', + body: { + ids: !isIdPInitiatedLogin ? [stateRequestId] : [], + content: samlResponse, + realm: this.realm, + }, + }) + ).body as any; } catch (err) { this.logger.debug(`Failed to log in with SAML response: ${err.message}`); @@ -541,12 +547,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { 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.callAsInternalUser( - 'shield.samlPrepare', - { + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { id: requestId, redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/prepare', body: { realm: this.realm }, - } - ); + }) + ).body as any; this.logger.debug('Redirecting to Identity Provider with SAML request.'); @@ -570,9 +579,15 @@ export class SAMLAuthenticationProvider 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/saml/logout`. - const { redirect } = await this.options.client.callAsInternalUser('shield.samlLogout', { - body: { token: accessToken, refresh_token: refreshToken }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/logout', + body: { token: accessToken, refresh_token: refreshToken }, + }) + ).body as any; this.logger.debug('User session has been successfully invalidated.'); @@ -589,13 +604,19 @@ export class SAMLAuthenticationProvider 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/saml/invalidate`. - 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) : '', - realm: this.realm, - }, - }); + // We can replace generic `transport.request` with a dedicated API method call once + // https://github.com/elastic/elasticsearch/issues/67189 is resolved. + const { redirect } = ( + await this.options.client.asInternalUser.transport.request({ + method: 'POST', + path: '/_security/saml/invalidate', + // Elasticsearch expects `queryString` without leading `?`, so we should strip it with `slice`. + body: { + queryString: request.url.search ? request.url.search.slice(1) : '', + realm: this.realm, + }, + }) + ).body as any; this.logger.debug('User session has been successfully invalidated.'); diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 5a600461ef467..ad100ac5be893 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -5,31 +5,27 @@ */ import Boom from '@hapi/boom'; -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { - LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - ScopeableRequest, -} from '../../../../../../src/core/server'; +import { ScopeableRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { TokenAuthenticationProvider } from './token'; function expectAuthenticateCall( - mockClusterClient: jest.Mocked, + mockClusterClient: ReturnType, scopeableRequest: ScopeableRequest ) { expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.asCurrentUser.security.authenticate).toHaveBeenCalledTimes(1); } describe('TokenAuthenticationProvider', () => { @@ -51,11 +47,15 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - access_token: tokenPair.accessToken, - refresh_token: tokenPair.refreshToken, - authentication: user, - }); + mockOptions.client.asInternalUser.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + authentication: user, + }, + }) + ); await expect(provider.login(request, credentials)).resolves.toEqual( AuthenticationResult.succeeded( @@ -65,8 +65,8 @@ describe('TokenAuthenticationProvider', () => { ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'password', ...credentials }, }); }); @@ -75,17 +75,18 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const credentials = { username: 'user', password: 'password' }; - const authenticationError = new Error('Invalid credentials'); - mockOptions.client.callAsInternalUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + mockOptions.client.asInternalUser.security.getToken.mockRejectedValue(authenticationError); await expect(provider.login(request, credentials)).resolves.toEqual( AuthenticationResult.failed(authenticationError) ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'password', ...credentials }, }); @@ -158,8 +159,10 @@ describe('TokenAuthenticationProvider', () => { const user = mockAuthenticatedUser(); const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResolvedValue( + securityMock.createApiResponse({ body: user }) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -179,9 +182,9 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -212,9 +215,13 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = `Bearer ${tokenPair.accessToken}`; - const authenticationError = new errors.InternalServerError('something went wrong'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + const authenticationError = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + authenticationError + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -231,13 +238,15 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const refreshError = new errors.InternalServerError('failed to refresh token'); + const refreshError = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: {} }) + ); mockOptions.tokens.refresh.mockRejectedValue(refreshError); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( @@ -257,9 +266,9 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -288,9 +297,9 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); @@ -319,9 +328,9 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 67c2d244e75a2..3afbc25122a26 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -7,6 +7,8 @@ import Boom from '@hapi/boom'; import { KibanaRequest } from '../../../../../../src/core/server'; import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; @@ -65,9 +67,13 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { access_token: accessToken, refresh_token: refreshToken, authentication: authenticationInfo, - } = await this.options.client.callAsInternalUser('shield.getAccessToken', { - body: { grant_type: 'password', username, password }, - }); + } = ( + await this.options.client.asInternalUser.security.getToken<{ + access_token: string; + refresh_token: string; + authentication: AuthenticationInfo; + }>({ body: { grant_type: 'password', username, password } }) + ).body; this.logger.debug('Get token API request to Elasticsearch successful'); return AuthenticationResult.succeeded( @@ -80,7 +86,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } ); } catch (err) { - this.logger.debug(`Failed to perform a login: ${err.message}`); + this.logger.debug(`Failed to perform a login: ${getDetailedErrorMessage(err)}`); return AuthenticationResult.failed(err); } } diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index 18fdcf8608d29..eb439d74a46d0 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -4,25 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { errors } from 'elasticsearch'; +import { errors } from '@elastic/elasticsearch'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { securityMock } from '../mocks'; -import { - ILegacyClusterClient, - LegacyElasticsearchErrorHelpers, -} from '../../../../../src/core/server'; +import { ElasticsearchClient } from '../../../../../src/core/server'; import { Tokens } from './tokens'; describe('Tokens', () => { let tokens: Tokens; - let mockClusterClient: jest.Mocked; + let mockElasticsearchClient: DeeplyMockedKeys; beforeEach(() => { - mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + mockElasticsearchClient = elasticsearchServiceMock.createElasticsearchClient(); const tokensOptions = { - client: mockClusterClient, + client: mockElasticsearchClient, logger: loggingSystemMock.create().get(), }; @@ -33,9 +32,24 @@ describe('Tokens', () => { const nonExpirationErrors = [ {}, new Error(), - new errors.InternalServerError(), - new errors.Forbidden(), + new errors.NoLivingConnectionsError( + 'Server is not available', + securityMock.createApiResponse({ body: {} }) + ), + new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 403, + body: { error: { reason: 'forbidden' } }, + }) + ), { statusCode: 500, body: { error: { reason: 'some unknown reason' } } }, + new errors.NoLivingConnectionsError( + 'Server is not available', + securityMock.createApiResponse({ + statusCode: 500, + body: { error: { reason: 'some unknown reason' } }, + }) + ), ]; for (const error of nonExpirationErrors) { expect(Tokens.isAccessTokenExpiredError(error)).toBe(false); @@ -43,8 +57,10 @@ describe('Tokens', () => { const expirationErrors = [ { statusCode: 401 }, - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()), - new errors.AuthenticationException(), + securityMock.createApiResponse({ + statusCode: 401, + body: { error: { reason: 'unauthenticated' } }, + }), ]; for (const error of expirationErrors) { expect(Tokens.isAccessTokenExpiredError(error)).toBe(true); @@ -55,25 +71,30 @@ describe('Tokens', () => { 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); + const refreshFailureReason = new errors.NoLivingConnectionsError( + 'Server is not available', + securityMock.createApiResponse({ body: {} }) + ); + mockElasticsearchClient.security.getToken.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).rejects.toBe(refreshFailureReason); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledWith({ 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); + const refreshFailureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 400, body: {} }) + ); + mockElasticsearchClient.security.getToken.mockRejectedValue(refreshFailureReason); await expect(tokens.refresh(refreshToken)).resolves.toBe(null); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }); }); @@ -81,19 +102,23 @@ describe('Tokens', () => { it('returns token pair if refresh API call succeeds', async () => { const authenticationInfo = mockAuthenticatedUser(); const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ - access_token: tokenPair.accessToken, - refresh_token: tokenPair.refreshToken, - authentication: authenticationInfo, - }); + mockElasticsearchClient.security.getToken.mockResolvedValue( + securityMock.createApiResponse({ + body: { + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + authentication: authenticationInfo, + }, + }) + ); await expect(tokens.refresh(refreshToken)).resolves.toEqual({ authenticationInfo, ...tokenPair, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.getToken).toHaveBeenCalledWith({ body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }); }); @@ -101,158 +126,165 @@ describe('Tokens', () => { describe('invalidate()', () => { for (const [description, failureReason] of [ - ['an unknown error', new Error('failed to delete token')], - ['a 404 error without body', { statusCode: 404 }], + [ + 'an unknown error', + new errors.ResponseError( + securityMock.createApiResponse( + securityMock.createApiResponse({ body: { message: 'failed to delete token' } }) + ) + ), + ], + [ + 'a 404 error without body', + new errors.ResponseError( + securityMock.createApiResponse( + securityMock.createApiResponse({ statusCode: 404, body: {} }) + ) + ), + ], ] as Array<[string, object]>) { it(`throws if call to delete access token responds with ${description}`, async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + mockElasticsearchClient.security.invalidateToken.mockImplementation((args: any) => { if (args && args.body && args.body.token) { - return Promise.reject(failureReason); + return Promise.reject(failureReason) as any; } - return Promise.resolve({ invalidated_tokens: 1 }); + return Promise.resolve( + securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + ) as any; }); 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 }, - } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); it(`throws if call to delete refresh token responds with ${description}`, async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + mockElasticsearchClient.security.invalidateToken.mockImplementation((args: any) => { if (args && args.body && args.body.refresh_token) { - return Promise.reject(failureReason); + return Promise.reject(failureReason) as any; } - return Promise.resolve({ invalidated_tokens: 1 }); + return Promise.resolve( + securityMock.createApiResponse({ body: { invalidated_tokens: 1 } }) + ) as any; }); 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 }, - } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); } it('invalidates all provided tokens', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { 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 } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + 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 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { 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 } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + 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 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { 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 } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); for (const [description, response] of [ - ['none of the tokens were invalidated', Promise.resolve({ invalidated_tokens: 0 })], + [ + 'none of the tokens were invalidated', + Promise.resolve(securityMock.createApiResponse({ body: { invalidated_tokens: 0 } })), + ], [ '404 error is returned', - Promise.reject({ statusCode: 404, body: { invalidated_tokens: 0 } }), + Promise.resolve( + securityMock.createApiResponse({ statusCode: 404, body: { invalidated_tokens: 0 } }) + ), ], - ] as Array<[string, Promise]>) { + ] as Array<[string, any]>) { it(`does not fail if ${description}`, async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockClusterClient.callAsInternalUser.mockImplementation(() => response); + mockElasticsearchClient.security.invalidateToken.mockImplementation(() => response); 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 }, - } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + 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 }); + mockElasticsearchClient.security.invalidateToken.mockResolvedValue( + securityMock.createApiResponse({ body: { 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 } } - ); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { token: tokenPair.accessToken }, + }); + expect(mockElasticsearchClient.security.invalidateToken).toHaveBeenCalledWith({ + body: { refresh_token: tokenPair.refreshToken }, + }); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index a435452ae112f..7bee3dfe1c5a0 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import type { ElasticsearchClient, Logger } from '../../../../../src/core/server'; import type { AuthenticationInfo } from '../elasticsearch'; import { getErrorStatusCode } from '../errors'; @@ -42,9 +42,7 @@ export class Tokens { */ private readonly logger: Logger; - constructor( - private readonly options: Readonly<{ client: ILegacyClusterClient; logger: Logger }> - ) { + constructor(private readonly options: Readonly<{ client: ElasticsearchClient; logger: Logger }>) { this.logger = options.logger; } @@ -59,9 +57,13 @@ export class Tokens { access_token: accessToken, refresh_token: refreshToken, authentication: authenticationInfo, - } = await this.options.client.callAsInternalUser('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken }, - }); + } = ( + await this.options.client.security.getToken<{ + access_token: string; + refresh_token: string; + authentication: AuthenticationInfo; + }>({ body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken } }) + ).body; this.logger.debug('Access token has been successfully refreshed.'); @@ -108,10 +110,10 @@ export class Tokens { let invalidatedTokensCount; try { invalidatedTokensCount = ( - await this.options.client.callAsInternalUser('shield.deleteAccessToken', { + await this.options.client.security.invalidateToken<{ invalidated_tokens: number }>({ body: { refresh_token: refreshToken }, }) - ).invalidated_tokens; + ).body.invalidated_tokens; } catch (err) { this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); @@ -140,10 +142,10 @@ export class Tokens { let invalidatedTokensCount; try { invalidatedTokensCount = ( - await this.options.client.callAsInternalUser('shield.deleteAccessToken', { + await this.options.client.security.invalidateToken<{ invalidated_tokens: number }>({ body: { token: accessToken }, }) - ).invalidated_tokens; + ).body.invalidated_tokens; } catch (err) { this.logger.debug(`Failed to invalidate access token: ${err.message}`); diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts deleted file mode 100644 index 0aaad251ae642..0000000000000 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export function elasticsearchClientPlugin(Client: any, config: unknown, components: any) { - const ca = components.clientAction.factory; - - Client.prototype.shield = components.clientAction.namespaceFactory(); - const shield = Client.prototype.shield.prototype; - - /** - * Perform a [shield.authenticate](Retrieve details about the currently authenticated user) request - * - * @param {Object} params - An object with parameters used to carry out this action - */ - shield.authenticate = ca({ - params: {}, - url: { - fmt: '/_security/_authenticate', - }, - }); - - /** - * Asks Elasticsearch to prepare SAML authentication request to be sent to - * the 3rd-party SAML identity provider. - * - * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL - * in the Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch - * will choose the right SAML realm. - * - * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. - * - * @returns {{realm: string, id: string, redirect: string}} Object that includes identifier - * of the SAML realm used to prepare authentication request, encrypted request token to be - * sent to Elasticsearch with SAML response and redirect URL to the identity provider that - * will be used to authenticate user. - */ - shield.samlPrepare = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/prepare', - }, - }); - - /** - * Sends SAML response returned by identity provider to Elasticsearch for validation. - * - * @param {Array.} ids A list of encrypted request tokens returned within SAML - * preparation response. - * @param {string} content SAML response returned by identity provider. - * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm - * that should be used to authenticate request. - * - * @returns {{username: string, access_token: string, expires_in: number}} Object that - * includes name of the user, access token to use for any consequent requests that - * need to be authenticated and a number of seconds after which access token will expire. - */ - shield.samlAuthenticate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/authenticate', - }, - }); - - /** - * Invalidates SAML access token. - * - * @param {string} token SAML access token that needs to be invalidated. - * - * @returns {{redirect?: string}} - */ - shield.samlLogout = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/logout', - }, - }); - - /** - * Invalidates SAML session based on Logout Request received from the Identity Provider. - * - * @param {string} queryString URL encoded query string provided by Identity Provider. - * @param {string} [acs] Optional assertion consumer service URL to use for SAML request or URL in the - * Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch - * will choose the right SAML realm to invalidate session. - * @param {string} [realm] Optional name of the Elasticsearch SAML realm to use to handle request. - * - * @returns {{redirect?: string}} - */ - shield.samlInvalidate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/saml/invalidate', - }, - }); - - /** - * Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to - * the 3rd-party OpenID Connect provider. - * - * @param {string} realm The OpenID Connect realm name in Elasticsearch - * - * @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need - * to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that - * will be used to authenticate user. - */ - shield.oidcPrepare = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/prepare', - }, - }); - - /** - * Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation. - * - * @param {string} state The state parameter that was returned by Elasticsearch in the - * preparation response. - * @param {string} nonce The nonce parameter that was returned by Elasticsearch in the - * preparation response. - * @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider. - * @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm - * that should be used to authenticate request. - * - * @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that - * includes name of the user, access token to use for any consequent requests that - * need to be authenticated and a number of seconds after which access token will expire. - */ - shield.oidcAuthenticate = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/authenticate', - }, - }); - - /** - * Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication. - * - * @param {string} token An access token that was created by authenticating to an OpenID Connect realm and - * that needs to be invalidated. - * @param {string} refresh_token A refresh token that was created by authenticating to an OpenID Connect realm and - * that needs to be invalidated. - * - * @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the - * OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA - */ - shield.oidcLogout = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oidc/logout', - }, - }); - - /** - * Refreshes an access token. - * - * @param {string} grant_type Currently only "refresh_token" grant type is supported. - * @param {string} refresh_token One-time refresh token that will be exchanged to the new access/refresh token pair. - * - * @returns {{access_token: string, type: string, expires_in: number, refresh_token: string}} - */ - shield.getAccessToken = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/oauth2/token', - }, - }); - - /** - * Invalidates an access token. - * - * @param {string} token The access token to invalidate - * - * @returns {{created: boolean}} - */ - shield.deleteAccessToken = ca({ - method: 'DELETE', - needBody: true, - params: { - token: { - type: 'string', - }, - }, - url: { - fmt: '/_security/oauth2/token', - }, - }); - - /** - * Gets an access token in exchange to the certificate chain for the target subject distinguished name. - * - * @param {string[]} x509_certificate_chain An ordered array of base64-encoded (Section 4 of RFC4648 - not - * base64url-encoded) DER PKIX certificate values. - * - * @returns {{access_token: string, type: string, expires_in: number}} - */ - shield.delegatePKI = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/delegate_pki', - }, - }); -} diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts index 812e3e3d17f99..e58cc4b2caa52 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts @@ -5,20 +5,11 @@ */ import { BehaviorSubject } from 'rxjs'; -import { - ILegacyCustomClusterClient, - ServiceStatusLevels, - CoreStatus, -} from '../../../../../src/core/server'; +import { ServiceStatusLevels, CoreStatus } from '../../../../../src/core/server'; import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { ElasticsearchService } from './elasticsearch_service'; -import { - coreMock, - elasticsearchServiceMock, - loggingSystemMock, -} from '../../../../../src/core/server/mocks'; +import { coreMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { nextTick } from '@kbn/test/jest'; @@ -30,35 +21,20 @@ describe('ElasticsearchService', () => { describe('setup()', () => { it('exposes proper contract', () => { - const mockCoreSetup = coreMock.createSetup(); - const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - expect( service.setup({ - elasticsearch: mockCoreSetup.elasticsearch, - status: mockCoreSetup.status, + status: coreMock.createSetup().status, license: licenseMock.create(), }) - ).toEqual({ clusterClient: mockClusterClient }); - - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledTimes(1); - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledWith('security', { - plugins: [elasticsearchClientPlugin], - }); + ).toBeUndefined(); }); }); describe('start()', () => { - let mockClusterClient: ILegacyCustomClusterClient; let mockLicense: jest.Mocked; let mockStatusSubject: BehaviorSubject; let mockLicenseSubject: BehaviorSubject; beforeEach(() => { - const mockCoreSetup = coreMock.createSetup(); - mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - mockLicenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); mockLicense = licenseMock.create(); mockLicense.isEnabled.mockReturnValue(false); @@ -71,20 +47,18 @@ describe('ElasticsearchService', () => { }, savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, }); - mockCoreSetup.status.core$ = mockStatusSubject; + + const mockStatus = coreMock.createSetup().status; + mockStatus.core$ = mockStatusSubject; service.setup({ - elasticsearch: mockCoreSetup.elasticsearch, - status: mockCoreSetup.status, + status: mockStatus, license: mockLicense, }); }); it('exposes proper contract', () => { - expect(service.start()).toEqual({ - clusterClient: mockClusterClient, - watchOnlineStatus$: expect.any(Function), - }); + expect(service.start()).toEqual({ watchOnlineStatus$: expect.any(Function) }); }); it('`watchOnlineStatus$` allows tracking of Elasticsearch status', () => { @@ -199,24 +173,4 @@ describe('ElasticsearchService', () => { expect(mockHandler).toHaveBeenCalledTimes(2); }); }); - - describe('stop()', () => { - it('properly closes cluster client instance', () => { - const mockCoreSetup = coreMock.createSetup(); - const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - - service.setup({ - elasticsearch: mockCoreSetup.elasticsearch, - status: mockCoreSetup.status, - license: licenseMock.create(), - }); - - expect(mockClusterClient.close).not.toHaveBeenCalled(); - - service.stop(); - - expect(mockClusterClient.close).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts index 42a83b2e5b527..ace1dc553890d 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts @@ -6,29 +6,15 @@ import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, shareReplay, tap } from 'rxjs/operators'; -import { - ILegacyClusterClient, - ILegacyCustomClusterClient, - Logger, - ServiceStatusLevels, - StatusServiceSetup, - ElasticsearchServiceSetup as CoreElasticsearchServiceSetup, -} from '../../../../../src/core/server'; +import { Logger, ServiceStatusLevels, StatusServiceSetup } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; export interface ElasticsearchServiceSetupParams { - readonly elasticsearch: CoreElasticsearchServiceSetup; readonly status: StatusServiceSetup; readonly license: SecurityLicense; } -export interface ElasticsearchServiceSetup { - readonly clusterClient: ILegacyClusterClient; -} - export interface ElasticsearchServiceStart { - readonly clusterClient: ILegacyClusterClient; readonly watchOnlineStatus$: () => Observable; } @@ -41,22 +27,13 @@ export interface OnlineStatusRetryScheduler { */ export class ElasticsearchService { readonly #logger: Logger; - #clusterClient?: ILegacyCustomClusterClient; #coreStatus$!: Observable; constructor(logger: Logger) { this.#logger = logger; } - setup({ - elasticsearch, - status, - license, - }: ElasticsearchServiceSetupParams): ElasticsearchServiceSetup { - this.#clusterClient = elasticsearch.legacy.createClient('security', { - plugins: [elasticsearchClientPlugin], - }); - + setup({ status, license }: ElasticsearchServiceSetupParams) { this.#coreStatus$ = combineLatest([status.core$, license.features$]).pipe( map( ([coreStatus]) => @@ -64,14 +41,10 @@ export class ElasticsearchService { ), shareReplay(1) ); - - return { clusterClient: this.#clusterClient }; } start(): ElasticsearchServiceStart { return { - clusterClient: this.#clusterClient!, - // We'll need to get rid of this as soon as Core's Elasticsearch service exposes this // functionality in the scope of https://github.com/elastic/kibana/issues/41983. watchOnlineStatus$: () => { @@ -120,11 +93,4 @@ export class ElasticsearchService { }, }; } - - stop() { - if (this.#clusterClient) { - this.#clusterClient.close(); - this.#clusterClient = undefined; - } - } } diff --git a/x-pack/plugins/security/server/elasticsearch/index.ts b/x-pack/plugins/security/server/elasticsearch/index.ts index 23e4876904c31..c770600db44cd 100644 --- a/x-pack/plugins/security/server/elasticsearch/index.ts +++ b/x-pack/plugins/security/server/elasticsearch/index.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuthenticatedUser } from '../../common/model'; +import type { AuthenticatedUser } from '../../common/model'; export type AuthenticationInfo = Omit; export { ElasticsearchService, - ElasticsearchServiceSetup, ElasticsearchServiceStart, OnlineStatusRetryScheduler, } from './elasticsearch_service'; diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index df30d1bf9d6f6..7d8f3cf36a4ad 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -14,7 +14,7 @@ function createSetupMock() { const mockAuthz = authorizationMock.create(); return { audit: auditServiceMock.create(), - authc: authenticationServiceMock.createSetup(), + authc: { getCurrentUser: jest.fn() }, authz: { actions: mockAuthz.actions, checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 54efdbdccbb77..256eca376fa02 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -6,11 +6,10 @@ import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; -import { ILegacyCustomClusterClient } from '../../../../src/core/server'; import { ConfigSchema } from './config'; import { Plugin, PluginSetupDependencies, PluginStartDependencies } from './plugin'; -import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; +import { coreMock } from '../../../../src/core/server/mocks'; import { featuresPluginMock } from '../../features/server/mocks'; import { taskManagerMock } from '../../task_manager/server/mocks'; import { licensingMock } from '../../licensing/server/mocks'; @@ -19,7 +18,6 @@ describe('Security Plugin', () => { let plugin: Plugin; let mockCoreSetup: ReturnType; let mockCoreStart: ReturnType; - let mockClusterClient: jest.Mocked; let mockSetupDependencies: PluginSetupDependencies; let mockStartDependencies: PluginStartDependencies; beforeEach(() => { @@ -43,9 +41,6 @@ describe('Security Plugin', () => { protocol: 'https', }); - mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); - mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - mockSetupDependencies = ({ licensing: { license$: of({}), featureUsage: { register: jest.fn() } }, features: featuresPluginMock.createSetup(), diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 1016221cb719d..8d8e4c096f37e 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -12,6 +12,7 @@ import { SecurityOssPluginSetup } from 'src/plugins/security_oss/server'; import { CoreSetup, CoreStart, + KibanaRequest, Logger, PluginInitializerContext, } from '../../../../src/core/server'; @@ -24,22 +25,19 @@ import { import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import { - AuthenticationService, - AuthenticationServiceSetup, - AuthenticationServiceStart, -} from './authentication'; +import { AuthenticationService, AuthenticationServiceStart } from './authentication'; import { AuthorizationService, AuthorizationServiceSetup } from './authorization'; import { AnonymousAccessService, AnonymousAccessServiceStart } from './anonymous_access'; import { ConfigSchema, ConfigType, createConfig } from './config'; import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; +import { AuthenticatedUser } from '../common/model'; import { setupSavedObjects } from './saved_objects'; import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage'; import { securityFeatures } from './features'; import { ElasticsearchService } from './elasticsearch'; -import { SessionManagementService } from './session_management'; +import { Session, SessionManagementService } from './session_management'; import { registerSecurityUsageCollector } from './usage_collector'; import { setupSpacesClient } from './spaces'; @@ -60,7 +58,7 @@ export interface SecurityPluginSetup { /** * @deprecated Use `authc` methods from the `SecurityServiceStart` contract instead. */ - authc: Pick; + authc: { getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null }; /** * @deprecated Use `authz` methods from the `SecurityServiceStart` contract instead. */ @@ -104,8 +102,8 @@ export interface PluginStartDependencies { */ export class Plugin { private readonly logger: Logger; - private authenticationStart?: AuthenticationServiceStart; private authorizationSetup?: AuthorizationServiceSetup; + private auditSetup?: AuditServiceSetup; private anonymousAccessStart?: AnonymousAccessServiceStart; private configSubscription?: Subscription; @@ -117,6 +115,14 @@ export class Plugin { return this.config; }; + private session?: Session; + private readonly getSession = () => { + if (!this.session) { + throw new Error('Session is not available.'); + } + return this.session; + }; + private kibanaIndexName?: string; private readonly getKibanaIndexName = () => { if (!this.kibanaIndexName) { @@ -125,6 +131,17 @@ export class Plugin { return this.kibanaIndexName; }; + private readonly authenticationService = new AuthenticationService( + this.initializerContext.logger.get('authentication') + ); + private authenticationStart?: AuthenticationServiceStart; + private readonly getAuthentication = () => { + if (!this.authenticationStart) { + throw new Error(`authenticationStart is not registered!`); + } + return this.authenticationStart; + }; + private readonly featureUsageService = new SecurityFeatureUsageService(); private featureUsageServiceStart?: SecurityFeatureUsageServiceStart; private readonly getFeatureUsageService = () => { @@ -143,9 +160,6 @@ export class Plugin { private readonly sessionManagementService = new SessionManagementService( this.initializerContext.logger.get('session') ); - private readonly authenticationService = new AuthenticationService( - this.initializerContext.logger.get('authentication') - ); private readonly anonymousAccessService = new AnonymousAccessService( this.initializerContext.logger.get('anonymous-access'), this.getConfig @@ -211,46 +225,22 @@ export class Plugin { features.registerElasticsearchFeature(securityFeature) ); - const { clusterClient } = this.elasticsearchService.setup({ - elasticsearch: core.elasticsearch, - license, - status: core.status, - }); - + this.elasticsearchService.setup({ license, status: core.status }); this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); + this.sessionManagementService.setup({ config, http: core.http, taskManager }); + this.authenticationService.setup({ http: core.http, license }); registerSecurityUsageCollector({ usageCollection, config, license }); - const { session } = this.sessionManagementService.setup({ - config, - clusterClient, - http: core.http, - kibanaIndexName, - taskManager, - }); - - const audit = this.auditService.setup({ + this.auditSetup = this.auditService.setup({ license, config: config.audit, logging: core.logging, http: core.http, getSpaceId: (request) => spaces?.spacesService.getSpaceId(request), - getSID: (request) => session.getSID(request), - getCurrentUser: (request) => authenticationSetup.getCurrentUser(request), - recordAuditLoggingUsage: () => this.featureUsageServiceStart?.recordAuditLoggingUsage(), - }); - const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger()); - - const authenticationSetup = this.authenticationService.setup({ - legacyAuditLogger, - audit, - getFeatureUsageService: this.getFeatureUsageService, - http: core.http, - clusterClient, - config, - license, - loggers: this.initializerContext.logger, - session, + getSID: (request) => this.getSession().getSID(request), + getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request), + recordAuditLoggingUsage: () => this.getFeatureUsageService().recordAuditLoggingUsage(), }); this.anonymousAccessService.setup(); @@ -267,18 +257,18 @@ export class Plugin { buildNumber: this.initializerContext.env.packageInfo.buildNum, getSpacesService: () => spaces?.spacesService, features, - getCurrentUser: authenticationSetup.getCurrentUser, + getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request), }); setupSpacesClient({ spaces, - audit, + audit: this.auditSetup, authz: this.authorizationSetup, }); setupSavedObjects({ - legacyAuditLogger, - audit, + legacyAuditLogger: new SecurityAuditLogger(this.auditSetup.getLogger()), + audit: this.auditSetup, authz: this.authorizationSetup, savedObjects: core.savedObjects, getSpacesService: () => spaces?.spacesService, @@ -292,26 +282,20 @@ export class Plugin { config, authz: this.authorizationSetup, license, - session, + getSession: this.getSession, getFeatures: () => startServicesPromise.then((services) => services.features.getKibanaFeatures()), getFeatureUsageService: this.getFeatureUsageService, - getAuthenticationService: () => { - if (!this.authenticationStart) { - throw new Error('Authentication service is not started!'); - } - - return this.authenticationStart; - }, + getAuthenticationService: this.getAuthentication, }); return Object.freeze({ audit: { - asScoped: audit.asScoped, - getLogger: audit.getLogger, + asScoped: this.auditSetup.asScoped, + getLogger: this.auditSetup.getLogger, }, - authc: { getCurrentUser: authenticationSetup.getCurrentUser }, + authc: { getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request) }, authz: { actions: this.authorizationSetup.actions, @@ -337,11 +321,24 @@ export class Plugin { const clusterClient = core.elasticsearch.client; const { watchOnlineStatus$ } = this.elasticsearchService.start(); + const { session } = this.sessionManagementService.start({ + elasticsearchClient: clusterClient.asInternalUser, + kibanaIndexName: this.getKibanaIndexName(), + online$: watchOnlineStatus$(), + taskManager, + }); + this.session = session; - this.sessionManagementService.start({ online$: watchOnlineStatus$(), taskManager }); + const config = this.getConfig(); this.authenticationStart = this.authenticationService.start({ - http: core.http, + audit: this.auditSetup!, clusterClient, + config, + featureUsageService: this.featureUsageServiceStart, + http: core.http, + legacyAuditLogger: new SecurityAuditLogger(this.auditSetup!.getLogger()), + loggers: this.initializerContext.logger, + session, }); this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); @@ -391,7 +388,6 @@ export class Plugin { this.securityLicenseService.stop(); this.auditService.stop(); this.authorizationService.stop(); - this.elasticsearchService.stop(); this.sessionManagementService.stop(); } } diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 4103594faba15..f7b51eeffe6ed 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -32,7 +32,7 @@ export const routeDefinitionParamsMock = { httpResources: httpResourcesMock.createRegistrar(), getFeatures: jest.fn(), getFeatureUsageService: jest.fn(), - session: sessionMock.create(), + getSession: jest.fn().mockReturnValue(sessionMock.create()), getAuthenticationService: jest.fn().mockReturnValue(authenticationServiceMock.createStart()), } as unknown) as DeeplyMockedKeys), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 2d49329fd63d3..9a8fe2e0d6d15 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -33,7 +33,7 @@ export interface RouteDefinitionParams { logger: Logger; config: ConfigType; authz: AuthorizationServiceSetup; - session: PublicMethodsOf; + getSession: () => PublicMethodsOf; license: SecurityLicense; getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; diff --git a/x-pack/plugins/security/server/routes/session_management/info.test.ts b/x-pack/plugins/security/server/routes/session_management/info.test.ts index c51956f3fe530..b068e80cfa859 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.test.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.test.ts @@ -24,7 +24,9 @@ describe('Info session routes', () => { beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; - session = routeParamsMock.session; + + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); defineSessionInfoRoutes(routeParamsMock); }); diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts index 381127284f780..1f73edf510976 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -10,12 +10,12 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for the session info. */ -export function defineSessionInfoRoutes({ router, logger, session }: RouteDefinitionParams) { +export function defineSessionInfoRoutes({ router, logger, getSession }: RouteDefinitionParams) { router.get( { path: '/internal/security/session', validate: false }, async (_context, request, response) => { try { - const sessionValue = await session.get(request); + const sessionValue = await getSession().get(request); if (sessionValue) { return response.ok({ body: { diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index 24e73e456619b..2c7c3f6bafc40 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -48,7 +48,10 @@ describe('Change password', () => { beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; - session = routeParamsMock.session; + + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); + authc = authenticationServiceMock.createStart(); routeParamsMock.getAuthenticationService.mockReturnValue(authc); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index 7b53afceb48fd..1c9086862c37d 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -16,7 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineChangeUserPasswordRoutes({ getAuthenticationService, - session, + getSession, router, }: RouteDefinitionParams) { router.post( @@ -37,7 +37,7 @@ export function defineChangeUserPasswordRoutes({ const currentUser = getAuthenticationService().getCurrentUser(request); const isUserChangingOwnPassword = currentUser && currentUser.username === username && canUserChangePassword(currentUser); - const currentSession = isUserChangingOwnPassword ? await session.get(request) : null; + const currentSession = isUserChangingOwnPassword ? await getSession().get(request) : null; // If user is changing their own password they should provide a proof of knowledge their // current password via sending it in `Authorization: Basic base64(username:current password)` diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts index 4c4f8a22eee23..a471f5f4e84cb 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts @@ -33,10 +33,12 @@ describe('Access agreement view routes', () => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; httpResources = routeParamsMock.httpResources; - session = routeParamsMock.session; config = routeParamsMock.config; license = routeParamsMock.license; + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); + license.getFeatures.mockReturnValue({ allowAccessAgreement: true, } as SecurityLicenseFeatures); diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.ts b/x-pack/plugins/security/server/routes/views/access_agreement.ts index 80a1c2a20cf59..c7f694eca68ce 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.ts @@ -12,7 +12,7 @@ import { RouteDefinitionParams } from '..'; * Defines routes required for the Access Agreement view. */ export function defineAccessAgreementRoutes({ - session, + getSession, httpResources, license, config, @@ -46,7 +46,7 @@ export function defineAccessAgreementRoutes({ // authenticated with the help of HTTP authentication), that means we should safely check if // we have it and can get a corresponding configuration. try { - const sessionValue = await session.get(request); + const sessionValue = await getSession().get(request); const accessAgreement = (sessionValue && config.authc.providers[ diff --git a/x-pack/plugins/security/server/routes/views/logged_out.test.ts b/x-pack/plugins/security/server/routes/views/logged_out.test.ts index 31096bc33d686..7cc534663e2f9 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.test.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.test.ts @@ -18,7 +18,8 @@ describe('LoggedOut view routes', () => { let routeConfig: RouteConfig; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); - session = routeParamsMock.session; + session = sessionMock.create(); + routeParamsMock.getSession.mockReturnValue(session); defineLoggedOutRoutes(routeParamsMock); diff --git a/x-pack/plugins/security/server/routes/views/logged_out.ts b/x-pack/plugins/security/server/routes/views/logged_out.ts index b35154e6a0f2a..97357118907d3 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.ts @@ -17,7 +17,7 @@ import { RouteDefinitionParams } from '..'; */ export function defineLoggedOutRoutes({ logger, - session, + getSession, httpResources, basePath, }: RouteDefinitionParams) { @@ -30,7 +30,7 @@ export function defineLoggedOutRoutes({ async (context, request, response) => { // Authentication flow isn't triggered automatically for this route, so we should explicitly // check whether user has an active session already. - const isUserAlreadyLoggedIn = (await session.get(request)) !== null; + const isUserAlreadyLoggedIn = (await getSession().get(request)) !== null; if (isUserAlreadyLoggedIn) { logger.debug('User is already authenticated, redirecting...'); return response.redirected({ diff --git a/x-pack/plugins/security/server/session_management/index.ts b/x-pack/plugins/security/server/session_management/index.ts index ee7ed914947a0..1d256885f49f2 100644 --- a/x-pack/plugins/security/server/session_management/index.ts +++ b/x-pack/plugins/security/server/session_management/index.ts @@ -6,6 +6,6 @@ export { Session, SessionValue } from './session'; export { - SessionManagementServiceSetup, + SessionManagementServiceStart, SessionManagementService, } from './session_management_service'; diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts index 1dd47c7ff66e8..51abcfe00253c 100644 --- a/x-pack/plugins/security/server/session_management/session_index.test.ts +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -4,27 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyClusterClient } from '../../../../../src/core/server'; +import { errors } from '@elastic/elasticsearch'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { ElasticsearchClient } from '../../../../../src/core/server'; import { ConfigSchema, createConfig } from '../config'; import { getSessionIndexTemplate, SessionIndex } from './session_index'; import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { securityMock } from '../mocks'; import { sessionIndexMock } from './session_index.mock'; describe('Session index', () => { - let mockClusterClient: jest.Mocked; + let mockElasticsearchClient: DeeplyMockedKeys; let sessionIndex: SessionIndex; const indexName = '.kibana_some_tenant_security_session_1'; const indexTemplateName = '.kibana_some_tenant_security_session_index_template_1'; beforeEach(() => { - mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + mockElasticsearchClient = elasticsearchServiceMock.createElasticsearchClient(); const sessionIndexOptions = { logger: loggingSystemMock.createLogger(), kibanaIndexName: '.kibana_some_tenant', config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }; sessionIndex = new SessionIndex(sessionIndexOptions); @@ -32,22 +35,21 @@ describe('Session index', () => { describe('#initialize', () => { function assertExistenceChecksPerformed() { - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { + expect(mockElasticsearchClient.indices.existsTemplate).toHaveBeenCalledWith({ name: indexTemplateName, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.exists', { + expect(mockElasticsearchClient.indices.exists).toHaveBeenCalledWith({ index: getSessionIndexTemplate(indexName).index_patterns, }); } it('debounces initialize calls', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate' || method === 'indices.exists') { - return true; - } - - throw new Error('Unexpected call'); - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); await Promise.all([ sessionIndex.initialize(), @@ -56,112 +58,102 @@ describe('Session index', () => { sessionIndex.initialize(), ]); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); assertExistenceChecksPerformed(); }); it('creates neither index template nor index if they exist', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate' || method === 'indices.exists') { - return true; - } - - throw new Error('Unexpected call'); - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); await sessionIndex.initialize(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); assertExistenceChecksPerformed(); }); it('creates both index template and index if they do not exist', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate' || method === 'indices.exists') { - return false; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); await sessionIndex.initialize(); const expectedIndexTemplate = getSessionIndexTemplate(indexName); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(4); assertExistenceChecksPerformed(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + expect(mockElasticsearchClient.indices.putTemplate).toHaveBeenCalledWith({ name: indexTemplateName, body: expectedIndexTemplate, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + expect(mockElasticsearchClient.indices.create).toHaveBeenCalledWith({ index: expectedIndexTemplate.index_patterns, }); }); it('creates only index template if it does not exist even if index exists', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate') { - return false; - } - - if (method === 'indices.exists') { - return true; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); await sessionIndex.initialize(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); assertExistenceChecksPerformed(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + expect(mockElasticsearchClient.indices.putTemplate).toHaveBeenCalledWith({ name: indexTemplateName, body: getSessionIndexTemplate(indexName), }); }); it('creates only index if it does not exist even if index template exists', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate') { - return true; - } - - if (method === 'indices.exists') { - return false; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); await sessionIndex.initialize(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); assertExistenceChecksPerformed(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + expect(mockElasticsearchClient.indices.create).toHaveBeenCalledWith({ index: getSessionIndexTemplate(indexName).index_patterns, }); }); it('does not fail if tries to create index when it exists already', async () => { - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'indices.existsTemplate') { - return true; - } - - if (method === 'indices.exists') { - return false; - } - - if (method === 'indices.create') { - // eslint-disable-next-line no-throw-literal - throw { body: { error: { type: 'resource_already_exists_exception' } } }; - } - }); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValue( + securityMock.createApiResponse({ body: true }) + ); + mockElasticsearchClient.indices.exists.mockResolvedValue( + securityMock.createApiResponse({ body: false }) + ); + mockElasticsearchClient.indices.create.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ + body: { error: { type: 'resource_already_exists_exception' } }, + }) + ) + ); await sessionIndex.initialize(); }); it('works properly after failure', async () => { - const unexpectedError = new Error('Uh! Oh!'); - mockClusterClient.callAsInternalUser.mockImplementationOnce(() => - Promise.reject(unexpectedError) + const unexpectedError = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.indices.existsTemplate.mockRejectedValueOnce(unexpectedError); + mockElasticsearchClient.indices.existsTemplate.mockResolvedValueOnce( + securityMock.createApiResponse({ body: true }) ); - mockClusterClient.callAsInternalUser.mockImplementationOnce(() => Promise.resolve(true)); await expect(sessionIndex.initialize()).rejects.toBe(unexpectedError); await expect(sessionIndex.initialize()).resolves.toBe(undefined); @@ -171,13 +163,17 @@ describe('Session index', () => { describe('cleanUp', () => { const now = 123456; beforeEach(() => { - mockClusterClient.callAsInternalUser.mockResolvedValue({}); + mockElasticsearchClient.deleteByQuery.mockResolvedValue( + securityMock.createApiResponse({ body: {} }) + ); jest.spyOn(Date, 'now').mockImplementation(() => now); }); it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.deleteByQuery.mockRejectedValue(failureReason); await expect(sessionIndex.cleanUp()).rejects.toBe(failureReason); }); @@ -185,53 +181,55 @@ describe('Session index', () => { it('when neither `lifespan` nor `idleTimeout` is configured', async () => { await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider that are expired based on the idle timeout. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [{ range: { idleTimeoutExpiration: { lte: now } } }], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are expired based on the idle timeout. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [{ range: { idleTimeoutExpiration: { lte: now } } }], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when only `lifespan` is configured', async () => { @@ -243,68 +241,70 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a particular provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a particular provider that are expired based on the idle timeout. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [{ range: { idleTimeoutExpiration: { lte: now } } }], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are expired based on the idle timeout. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [{ range: { idleTimeoutExpiration: { lte: now } } }], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when only `idleTimeout` is configured', async () => { @@ -317,62 +317,64 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when both `lifespan` and `idleTimeout` are configured', async () => { @@ -385,72 +387,74 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a particular provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a particular provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a particular provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a particular provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); it('when both `lifespan` and `idleTimeout` are configured and multiple providers are enabled', async () => { @@ -478,127 +482,132 @@ describe('Session index', () => { loggingSystemMock.createLogger(), { isTLSEnabled: false } ), - clusterClient: mockClusterClient, + elasticsearchClient: mockElasticsearchClient, }); await sessionIndex.cleanUp(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( + { + index: indexName, + refresh: true, + body: { + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + }, }, - }, - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, }, }, - }, - // The sessions that belong to a Basic provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a Basic provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a Basic provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * globalIdleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a Basic provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * globalIdleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - // The sessions that belong to a SAML provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, + // The sessions that belong to a SAML provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, }, - }, - // The sessions that belong to a SAML provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * samlIdleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, + // The sessions that belong to a SAML provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * samlIdleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, }, }, - }); + { ignore: [409, 404] } + ); }); }); describe('#get', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.get.mockRejectedValue(failureReason); await expect(sessionIndex.get('some-sid')).rejects.toBe(failureReason); }); it('returns `null` if index is not found', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ status: 404 }); + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ statusCode: 404, body: { status: 404 } }) + ); await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); }); it('returns `null` if session index value document is not found', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ - found: false, - status: 200, - }); + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ body: { status: 200, found: false } }) + ); await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); }); @@ -612,13 +621,17 @@ describe('Session index', () => { content: 'some-encrypted-content', }; - mockClusterClient.callAsInternalUser.mockResolvedValue({ - found: true, - status: 200, - _source: indexDocumentSource, - _primary_term: 1, - _seq_no: 456, - }); + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ + body: { + found: true, + status: 200, + _source: indexDocumentSource, + _primary_term: 1, + _seq_no: 456, + }, + }) + ); await expect(sessionIndex.get('some-sid')).resolves.toEqual({ ...indexDocumentSource, @@ -626,19 +639,20 @@ describe('Session index', () => { metadata: { primaryTerm: 1, sequenceNumber: 456 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('get', { - id: 'some-sid', - ignore: [404], - index: indexName, - }); + expect(mockElasticsearchClient.get).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.get).toHaveBeenCalledWith( + { id: 'some-sid', index: indexName }, + { ignore: [404] } + ); }); }); describe('#create', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.create.mockRejectedValue(failureReason); await expect( sessionIndex.create({ @@ -653,10 +667,9 @@ describe('Session index', () => { }); it('properly stores session value in the index', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ - _primary_term: 321, - _seq_no: 654, - }); + mockElasticsearchClient.create.mockResolvedValue( + securityMock.createApiResponse({ body: { _primary_term: 321, _seq_no: 654 } }) + ); const sid = 'some-long-sid'; const sessionValue = { @@ -673,8 +686,8 @@ describe('Session index', () => { metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('create', { + expect(mockElasticsearchClient.create).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.create).toHaveBeenCalledWith({ id: sid, index: indexName, body: sessionValue, @@ -685,8 +698,10 @@ describe('Session index', () => { describe('#update', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.index.mockRejectedValue(failureReason); await expect(sessionIndex.update(sessionIndexMock.createValue())).rejects.toBe(failureReason); }); @@ -700,21 +715,20 @@ describe('Session index', () => { content: 'some-updated-encrypted-content', }; - mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { - if (method === 'get') { - return { + mockElasticsearchClient.get.mockResolvedValue( + securityMock.createApiResponse({ + body: { found: true, status: 200, _source: latestSessionValue, _primary_term: 321, _seq_no: 654, - }; - } - - if (method === 'index') { - return { status: 409 }; - } - }); + }, + }) + ); + mockElasticsearchClient.index.mockResolvedValue( + securityMock.createApiResponse({ statusCode: 409, body: { status: 409 } }) + ); const sid = 'some-long-sid'; const metadata = { primaryTerm: 123, sequenceNumber: 456 }; @@ -732,24 +746,23 @@ describe('Session index', () => { metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { - id: sid, - index: indexName, - body: sessionValue, - ifSeqNo: 456, - ifPrimaryTerm: 123, - refresh: 'wait_for', - ignore: [409], - }); + expect(mockElasticsearchClient.index).toHaveBeenCalledWith( + { + id: sid, + index: indexName, + body: sessionValue, + if_seq_no: 456, + if_primary_term: 123, + refresh: 'wait_for', + }, + { ignore: [409] } + ); }); it('properly stores session value in the index', async () => { - mockClusterClient.callAsInternalUser.mockResolvedValue({ - _primary_term: 321, - _seq_no: 654, - status: 200, - }); + mockElasticsearchClient.index.mockResolvedValue( + securityMock.createApiResponse({ body: { _primary_term: 321, _seq_no: 654, status: 200 } }) + ); const sid = 'some-long-sid'; const metadata = { primaryTerm: 123, sequenceNumber: 456 }; @@ -767,23 +780,27 @@ describe('Session index', () => { metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { - id: sid, - index: indexName, - body: sessionValue, - ifSeqNo: 456, - ifPrimaryTerm: 123, - refresh: 'wait_for', - ignore: [409], - }); + expect(mockElasticsearchClient.index).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.index).toHaveBeenCalledWith( + { + id: sid, + index: indexName, + body: sessionValue, + if_seq_no: 456, + if_primary_term: 123, + refresh: 'wait_for', + }, + { ignore: [409] } + ); }); }); describe('#clear', () => { it('throws if call to Elasticsearch fails', async () => { - const failureReason = new Error('Uh oh.'); - mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.delete.mockRejectedValue(failureReason); await expect(sessionIndex.clear('some-long-sid')).rejects.toBe(failureReason); }); @@ -791,13 +808,11 @@ describe('Session index', () => { it('properly removes session value from the index', async () => { await sessionIndex.clear('some-long-sid'); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('delete', { - id: 'some-long-sid', - index: indexName, - refresh: 'wait_for', - ignore: [404], - }); + expect(mockElasticsearchClient.delete).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.delete).toHaveBeenCalledWith( + { id: 'some-long-sid', index: indexName, refresh: 'wait_for' }, + { ignore: [404] } + ); }); }); }); diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index 45b2f4489c195..13250531d391e 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import type { ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import type { ElasticsearchClient, Logger } from '../../../../../src/core/server'; import type { AuthenticationProvider } from '../../common/model'; import type { ConfigType } from '../config'; export interface SessionIndexOptions { - readonly clusterClient: ILegacyClusterClient; + readonly elasticsearchClient: ElasticsearchClient; readonly kibanaIndexName: string; readonly config: Pick; readonly logger: Logger; @@ -137,11 +137,10 @@ export class SessionIndex { */ async get(sid: string) { try { - const response = await this.options.clusterClient.callAsInternalUser('get', { - id: sid, - ignore: [404], - index: this.indexName, - }); + const { body: response } = await this.options.elasticsearchClient.get( + { id: sid, index: this.indexName }, + { ignore: [404] } + ); const docNotFound = response.found === false; const indexNotFound = response.status === 404; @@ -176,9 +175,8 @@ export class SessionIndex { const { sid, ...sessionValueToStore } = sessionValue; try { const { - _primary_term: primaryTerm, - _seq_no: sequenceNumber, - } = await this.options.clusterClient.callAsInternalUser('create', { + body: { _primary_term: primaryTerm, _seq_no: sequenceNumber }, + } = await this.options.elasticsearchClient.create({ id: sid, // We cannot control whether index is created automatically during this operation or not. // But we can reduce probability of getting into a weird state when session is being created @@ -203,15 +201,17 @@ export class SessionIndex { async update(sessionValue: Readonly) { const { sid, metadata, ...sessionValueToStore } = sessionValue; try { - const response = await this.options.clusterClient.callAsInternalUser('index', { - id: sid, - index: this.indexName, - body: sessionValueToStore, - ifSeqNo: metadata.sequenceNumber, - ifPrimaryTerm: metadata.primaryTerm, - refresh: 'wait_for', - ignore: [409], - }); + const { body: response } = await this.options.elasticsearchClient.index( + { + id: sid, + index: this.indexName, + body: sessionValueToStore, + if_seq_no: metadata.sequenceNumber, + if_primary_term: metadata.primaryTerm, + refresh: 'wait_for', + }, + { ignore: [409] } + ); // We don't want to override changes that were made after we fetched session value or // re-create it if has been deleted already. If we detect such a case we discard changes and @@ -242,12 +242,10 @@ export class SessionIndex { try { // We don't specify primary term and sequence number as delete should always take precedence // over any updates that could happen in the meantime. - await this.options.clusterClient.callAsInternalUser('delete', { - id: sid, - index: this.indexName, - refresh: 'wait_for', - ignore: [404], - }); + await this.options.elasticsearchClient.delete( + { id: sid, index: this.indexName, refresh: 'wait_for' }, + { ignore: [404] } + ); } catch (err) { this.options.logger.error(`Failed to clear session value: ${err.message}`); throw err; @@ -267,10 +265,11 @@ export class SessionIndex { // Check if required index template exists. let indexTemplateExists = false; try { - indexTemplateExists = await this.options.clusterClient.callAsInternalUser( - 'indices.existsTemplate', - { name: sessionIndexTemplateName } - ); + indexTemplateExists = ( + await this.options.elasticsearchClient.indices.existsTemplate({ + name: sessionIndexTemplateName, + }) + ).body; } catch (err) { this.options.logger.error( `Failed to check if session index template exists: ${err.message}` @@ -283,7 +282,7 @@ export class SessionIndex { this.options.logger.debug('Session index template already exists.'); } else { try { - await this.options.clusterClient.callAsInternalUser('indices.putTemplate', { + await this.options.elasticsearchClient.indices.putTemplate({ name: sessionIndexTemplateName, body: getSessionIndexTemplate(this.indexName), }); @@ -298,9 +297,9 @@ export class SessionIndex { // always enabled, so we create session index explicitly. let indexExists = false; try { - indexExists = await this.options.clusterClient.callAsInternalUser('indices.exists', { - index: this.indexName, - }); + indexExists = ( + await this.options.elasticsearchClient.indices.exists({ index: this.indexName }) + ).body; } catch (err) { this.options.logger.error(`Failed to check if session index exists: ${err.message}`); return reject(err); @@ -311,9 +310,7 @@ export class SessionIndex { this.options.logger.debug('Session index already exists.'); } else { try { - await this.options.clusterClient.callAsInternalUser('indices.create', { - index: this.indexName, - }); + await this.options.elasticsearchClient.indices.create({ index: this.indexName }); this.options.logger.debug('Successfully created session index.'); } catch (err) { // There can be a race condition if index is created by another Kibana instance. @@ -399,12 +396,14 @@ export class SessionIndex { } try { - const response = await this.options.clusterClient.callAsInternalUser('deleteByQuery', { - index: this.indexName, - refresh: 'wait_for', - ignore: [409, 404], - body: { query: { bool: { should: deleteQueries } } }, - }); + const { body: response } = await this.options.elasticsearchClient.deleteByQuery( + { + index: this.indexName, + refresh: true, + body: { query: { bool: { should: deleteQueries } } }, + }, + { ignore: [409, 404] } + ); if (response.deleted > 0) { this.options.logger.debug( diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts index 08d4c491d1556..d3e5c876c9974 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.test.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -21,7 +21,7 @@ import { loggingSystemMock, } from '../../../../../src/core/server/mocks'; import { taskManagerMock } from '../../../task_manager/server/mocks'; -import { TaskManagerStartContract } from '../../../task_manager/server'; +import { TaskManagerStartContract, TaskRunCreatorFunction } from '../../../task_manager/server'; describe('SessionManagementService', () => { let service: SessionManagementService; @@ -30,21 +30,19 @@ describe('SessionManagementService', () => { }); describe('setup()', () => { - it('exposes proper contract', () => { + it('registers cleanup task', () => { const mockCoreSetup = coreMock.createSetup(); const mockTaskManager = taskManagerMock.createSetup(); expect( service.setup({ - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), http: mockCoreSetup.http, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - kibanaIndexName: '.kibana', taskManager: mockTaskManager, }) - ).toEqual({ session: expect.any(Session) }); + ).toBeUndefined(); expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledWith({ @@ -54,60 +52,35 @@ describe('SessionManagementService', () => { }, }); }); - - it('registers proper session index cleanup task runner', () => { - const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); - const mockTaskManager = taskManagerMock.createSetup(); - - const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); - mockClusterClient.callAsInternalUser.mockResolvedValue({}); - service.setup({ - clusterClient: mockClusterClient, - http: coreMock.createSetup().http, - config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { - isTLSEnabled: false, - }), - kibanaIndexName: '.kibana', - taskManager: mockTaskManager, - }); - - const [ - [ - { - [SESSION_INDEX_CLEANUP_TASK_NAME]: { createTaskRunner }, - }, - ], - ] = mockTaskManager.registerTaskDefinitions.mock.calls; - expect(mockSessionIndexCleanUp).not.toHaveBeenCalled(); - - const runner = createTaskRunner({} as any); - runner.run(); - expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(1); - - runner.run(); - expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(2); - }); }); describe('start()', () => { let mockSessionIndexInitialize: jest.SpyInstance; let mockTaskManager: jest.Mocked; + let sessionCleanupTaskRunCreator: TaskRunCreatorFunction; beforeEach(() => { mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); mockTaskManager = taskManagerMock.createStart(); mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); - const mockCoreSetup = coreMock.createSetup(); + const mockTaskManagerSetup = taskManagerMock.createSetup(); service.setup({ - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), - http: mockCoreSetup.http, + http: coreMock.createSetup().http, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - kibanaIndexName: '.kibana', - taskManager: taskManagerMock.createSetup(), + taskManager: mockTaskManagerSetup, }); + + const [ + [ + { + [SESSION_INDEX_CLEANUP_TASK_NAME]: { createTaskRunner }, + }, + ], + ] = mockTaskManagerSetup.registerTaskDefinitions.mock.calls; + sessionCleanupTaskRunCreator = createTaskRunner; }); afterEach(() => { @@ -117,13 +90,43 @@ describe('SessionManagementService', () => { it('exposes proper contract', () => { const mockStatusSubject = new Subject(); expect( - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }) - ).toBeUndefined(); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }) + ).toEqual({ session: expect.any(Session) }); + }); + + it('registers proper session index cleanup task runner', () => { + const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); + const mockStatusSubject = new Subject(); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); + + expect(mockSessionIndexCleanUp).not.toHaveBeenCalled(); + + const runner = sessionCleanupTaskRunCreator({} as any); + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(1); + + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(2); }); it('initializes session index and schedules session index cleanup task when Elasticsearch goes online', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); // ES isn't online yet. expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); @@ -155,7 +158,12 @@ describe('SessionManagementService', () => { it('removes old cleanup task if cleanup interval changes', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockTaskManager.get.mockResolvedValue({ schedule: { interval: '2000s' } } as any); @@ -185,7 +193,12 @@ describe('SessionManagementService', () => { it('does not remove old cleanup task if cleanup interval does not change', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockTaskManager.get.mockResolvedValue({ schedule: { interval: '3600s' } } as any); @@ -206,7 +219,12 @@ describe('SessionManagementService', () => { it('schedules retry if index initialization fails', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockSessionIndexInitialize.mockRejectedValue(new Error('ugh :/')); @@ -237,7 +255,12 @@ describe('SessionManagementService', () => { it('schedules retry if cleanup task registration fails', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); mockTaskManager.ensureScheduled.mockRejectedValue(new Error('ugh :/')); @@ -277,12 +300,10 @@ describe('SessionManagementService', () => { const mockCoreSetup = coreMock.createSetup(); service.setup({ - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), http: mockCoreSetup.http, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), - kibanaIndexName: '.kibana', taskManager: taskManagerMock.createSetup(), }); }); @@ -293,7 +314,12 @@ describe('SessionManagementService', () => { it('properly unsubscribes from status updates', () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + service.start({ + elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), + kibanaIndexName: '.kibana', + online$: mockStatusSubject.asObservable(), + taskManager: mockTaskManager, + }); service.stop(); diff --git a/x-pack/plugins/security/server/session_management/session_management_service.ts b/x-pack/plugins/security/server/session_management/session_management_service.ts index fc2e85d683d58..6bd9d8cb3a8fe 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -6,8 +6,8 @@ import { Observable, Subscription } from 'rxjs'; import { + ElasticsearchClient, HttpServiceSetup, - ILegacyClusterClient, Logger, SavedObjectsErrorHelpers, } from '../../../../../src/core/server'; @@ -21,17 +21,17 @@ import { Session } from './session'; export interface SessionManagementServiceSetupParams { readonly http: Pick; readonly config: ConfigType; - readonly clusterClient: ILegacyClusterClient; - readonly kibanaIndexName: string; readonly taskManager: TaskManagerSetupContract; } export interface SessionManagementServiceStartParams { + readonly elasticsearchClient: ElasticsearchClient; + readonly kibanaIndexName: string; readonly online$: Observable; readonly taskManager: TaskManagerStartContract; } -export interface SessionManagementServiceSetup { +export interface SessionManagementServiceStart { readonly session: Session; } @@ -46,34 +46,22 @@ export const SESSION_INDEX_CLEANUP_TASK_NAME = 'session_cleanup'; export class SessionManagementService { private statusSubscription?: Subscription; private sessionIndex!: SessionIndex; + private sessionCookie!: SessionCookie; private config!: ConfigType; private isCleanupTaskScheduled = false; constructor(private readonly logger: Logger) {} - setup({ - config, - clusterClient, - http, - kibanaIndexName, - taskManager, - }: SessionManagementServiceSetupParams): SessionManagementServiceSetup { + setup({ config, http, taskManager }: SessionManagementServiceSetupParams) { this.config = config; - const sessionCookie = new SessionCookie({ + this.sessionCookie = new SessionCookie({ config, createCookieSessionStorageFactory: http.createCookieSessionStorageFactory, serverBasePath: http.basePath.serverBasePath || '/', logger: this.logger.get('cookie'), }); - this.sessionIndex = new SessionIndex({ - config, - clusterClient, - kibanaIndexName, - logger: this.logger.get('index'), - }); - // Register task that will perform periodic session index cleanup. taskManager.registerTaskDefinitions({ [SESSION_INDEX_CLEANUP_TASK_NAME]: { @@ -81,18 +69,21 @@ export class SessionManagementService { createTaskRunner: () => ({ run: () => this.sessionIndex.cleanUp() }), }, }); - - return { - session: new Session({ - logger: this.logger, - sessionCookie, - sessionIndex: this.sessionIndex, - config, - }), - }; } - start({ online$, taskManager }: SessionManagementServiceStartParams) { + start({ + elasticsearchClient, + kibanaIndexName, + online$, + taskManager, + }: SessionManagementServiceStartParams): SessionManagementServiceStart { + this.sessionIndex = new SessionIndex({ + config: this.config, + elasticsearchClient, + kibanaIndexName, + logger: this.logger.get('index'), + }); + this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { try { await Promise.all([this.sessionIndex.initialize(), this.scheduleCleanupTask(taskManager)]); @@ -100,6 +91,15 @@ export class SessionManagementService { scheduleRetry(); } }); + + return { + session: new Session({ + logger: this.logger, + sessionCookie: this.sessionCookie, + sessionIndex: this.sessionIndex, + config: this.config, + }), + }; } stop() {