From 3cd361768a50b2c3e7e12285f376c89bbc8cd3a0 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 10 Jan 2022 11:52:09 +0100 Subject: [PATCH 1/2] Handle access tokens that expire after authentication stage. --- .../authentication_service.test.ts | 264 +++++++++++++++++- .../authentication/authentication_service.ts | 74 ++++- .../authentication/authenticator.test.ts | 195 ++++++++++++- .../server/authentication/authenticator.ts | 28 ++ x-pack/plugins/security/server/plugin.ts | 1 + .../kerberos.config.ts | 6 + .../security_api_integration/oidc.config.ts | 6 + .../security_api_integration/pki.config.ts | 6 + .../security_api_integration/saml.config.ts | 6 + .../tests/kerberos/kerberos_login.ts | 49 ++++ .../oidc/authorization_code_flow/oidc_auth.ts | 47 ++++ .../tests/pki/pki_auth.ts | 53 ++++ .../tests/saml/saml_login.ts | 70 ++++- .../tests/token/session.ts | 51 ++++ .../security_api_integration/token.config.ts | 7 + .../common/test_endpoints/server/index.ts | 4 +- .../test_endpoints/server/init_routes.ts | 65 ++++- 17 files changed, 901 insertions(+), 31 deletions(-) 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 d29c780e414ff..7d9a36f489b7c 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -10,18 +10,22 @@ jest.mock('./unauthenticated_page'); import { mockCanRedirectRequest } from './authentication_service.test.mocks'; -import Boom from '@hapi/boom'; +import { errors } from '@elastic/elasticsearch'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type { AuthenticationHandler, AuthToolkit, + ElasticsearchServiceSetup, HttpServiceSetup, HttpServiceStart, KibanaRequest, Logger, LoggerFactory, OnPreResponseToolkit, + UnauthorizedError, + UnauthorizedErrorHandler, + UnauthorizedErrorHandlerToolkit, } from 'src/core/server'; import { coreMock, @@ -31,9 +35,8 @@ import { loggingSystemMock, } from 'src/core/server/mocks'; -import type { SecurityLicense } from '../../common/licensing'; +import type { AuthenticatedUser, SecurityLicense } from '../../common'; import { licenseMock } from '../../common/licensing/index.mock'; -import type { AuthenticatedUser } from '../../common/model'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import type { AuditServiceSetup } from '../audit'; import { auditServiceMock } from '../audit/index.mock'; @@ -41,6 +44,7 @@ import type { ConfigType } from '../config'; import { ConfigSchema, createConfig } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { securityMock } from '../mocks'; import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { sessionMock } from '../session_management/session.mock'; @@ -52,6 +56,7 @@ describe('AuthenticationService', () => { let logger: jest.Mocked; let mockSetupAuthenticationParams: { http: jest.Mocked; + elasticsearch: jest.Mocked; config: ConfigType; license: jest.Mocked; buildNumber: number; @@ -68,13 +73,15 @@ describe('AuthenticationService', () => { beforeEach(() => { logger = loggingSystemMock.createLogger(); - const httpMock = coreMock.createSetup().http; + const coreSetupMock = coreMock.createSetup(); + const httpMock = coreSetupMock.http; (httpMock.basePath.prepend as jest.Mock).mockImplementation( (path) => `${httpMock.basePath.serverBasePath}${path}` ); (httpMock.basePath.get as jest.Mock).mockImplementation(() => httpMock.basePath.serverBasePath); mockSetupAuthenticationParams = { http: httpMock, + elasticsearch: coreSetupMock.elasticsearch, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.create().get(), { isTLSEnabled: false, }), @@ -120,6 +127,17 @@ describe('AuthenticationService', () => { ); }); + it('properly registers unauthorized error handler', () => { + service.setup(mockSetupAuthenticationParams); + + expect( + mockSetupAuthenticationParams.elasticsearch.setUnauthorizedErrorHandler + ).toHaveBeenCalledTimes(1); + expect( + mockSetupAuthenticationParams.elasticsearch.setUnauthorizedErrorHandler + ).toHaveBeenCalledWith(expect.any(Function)); + }); + it('properly registers onPreResponse handler', () => { service.setup(mockSetupAuthenticationParams); @@ -278,7 +296,9 @@ describe('AuthenticationService', () => { it('rejects with original `badRequest` error when `authenticate` fails to authenticate user', async () => { const mockResponse = httpServerMock.createLifecycleResponseFactory(); - const esError = Boom.badRequest('some message'); + const esError = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 400, body: 'some message' }) + ); authenticate.mockResolvedValue(AuthenticationResult.failed(esError)); await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); @@ -293,12 +313,19 @@ describe('AuthenticationService', () => { it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => { const mockResponse = httpServerMock.createLifecycleResponseFactory(); - const originalError = Boom.unauthorized('some message'); - (originalError.output.headers as { [key: string]: string })['WWW-Authenticate'] = [ - 'Basic realm="Access to prod", charset="UTF-8"', - 'Basic', - 'Negotiate', - ] as any; + const originalError = new errors.ResponseError( + securityMock.createApiResponse({ + statusCode: 403, + body: 'some message', + headers: { + 'WWW-Authenticate': [ + 'Basic realm="Access to prod", charset="UTF-8"', + 'Basic', + 'Negotiate', + ], + }, + }) + ); authenticate.mockResolvedValue( AuthenticationResult.failed(originalError, { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, @@ -329,6 +356,221 @@ describe('AuthenticationService', () => { }); }); + describe('unauthorized error handler', () => { + let unauthorizedErrorHandler: UnauthorizedErrorHandler; + let reauthenticate: jest.SpyInstance, [KibanaRequest]>; + let mockUnauthorizedErrorToolkit: jest.Mocked; + beforeEach(() => { + mockUnauthorizedErrorToolkit = { notHandled: jest.fn(), retry: jest.fn() }; + + service.start(mockStartAuthenticationParams); + + unauthorizedErrorHandler = + mockSetupAuthenticationParams.elasticsearch.setUnauthorizedErrorHandler.mock.calls[0][0]; + reauthenticate = + jest.requireMock('./authenticator').Authenticator.mock.instances[0].reauthenticate; + }); + + it('does not handle error if license is not available.', async () => { + mockSetupAuthenticationParams.license.isLicenseAvailable.mockReturnValue(false); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ) as UnauthorizedError; + + await unauthorizedErrorHandler( + { error: failureReason, request: httpServerMock.createKibanaRequest() }, + mockUnauthorizedErrorToolkit + ); + + expect(mockUnauthorizedErrorToolkit.notHandled).toHaveBeenCalledTimes(1); + expect(mockUnauthorizedErrorToolkit.retry).not.toHaveBeenCalled(); + expect(reauthenticate).not.toHaveBeenCalled(); + }); + + it('does not handle error when security is disabled in elasticsearch.', async () => { + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ) as UnauthorizedError; + + await unauthorizedErrorHandler( + { error: failureReason, request: httpServerMock.createKibanaRequest() }, + mockUnauthorizedErrorToolkit + ); + + expect(mockUnauthorizedErrorToolkit.notHandled).toHaveBeenCalledTimes(1); + expect(mockUnauthorizedErrorToolkit.retry).not.toHaveBeenCalled(); + expect(reauthenticate).not.toHaveBeenCalled(); + }); + + it('does not handle non-401 errors.', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 403, body: {} }) + ) as UnauthorizedError; + + await unauthorizedErrorHandler( + { error: failureReason, request: httpServerMock.createKibanaRequest() }, + mockUnauthorizedErrorToolkit + ); + + expect(mockUnauthorizedErrorToolkit.notHandled).toHaveBeenCalledTimes(1); + expect(mockUnauthorizedErrorToolkit.retry).not.toHaveBeenCalled(); + expect(reauthenticate).not.toHaveBeenCalled(); + }); + + it('does not handle error unless provider successfully returns new headers.', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ) as UnauthorizedError; + + const nonHandleableResults = [ + AuthenticationResult.notHandled(), + AuthenticationResult.failed( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 404, body: {} })) + ), + AuthenticationResult.redirectTo('some-url'), + AuthenticationResult.succeeded(mockAuthenticatedUser(), { + authResponseHeaders: { header: 'value' }, + }), + ]; + + const mockRequest = httpServerMock.createKibanaRequest(); + for (const result of nonHandleableResults) { + reauthenticate.mockResolvedValue(result); + + await unauthorizedErrorHandler( + { error: failureReason, request: mockRequest }, + mockUnauthorizedErrorToolkit + ); + + expect(mockUnauthorizedErrorToolkit.notHandled).toHaveBeenCalledTimes(1); + expect(mockUnauthorizedErrorToolkit.retry).not.toHaveBeenCalled(); + + expect(reauthenticate).toHaveBeenCalledTimes(1); + expect(reauthenticate).toHaveBeenCalledWith(mockRequest); + + mockUnauthorizedErrorToolkit.notHandled.mockClear(); + reauthenticate.mockClear(); + } + }); + + it('handles error if authentication succeeds and authentication headers are available.', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ) as UnauthorizedError; + + reauthenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockAuthenticatedUser(), { + authHeaders: { header: 'value' }, + }) + ); + + const mockRequest = httpServerMock.createKibanaRequest(); + await unauthorizedErrorHandler( + { error: failureReason, request: mockRequest }, + mockUnauthorizedErrorToolkit + ); + + expect(mockUnauthorizedErrorToolkit.retry).toHaveBeenCalledTimes(1); + expect(mockUnauthorizedErrorToolkit.retry).toHaveBeenCalledWith({ + authHeaders: { header: 'value' }, + }); + expect(mockUnauthorizedErrorToolkit.notHandled).not.toHaveBeenCalled(); + + expect(reauthenticate).toHaveBeenCalledTimes(1); + expect(reauthenticate).toHaveBeenCalledWith(mockRequest); + }); + + it('filters out and recovers `Authorization` header when provider cannot handle error.', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ) as UnauthorizedError; + + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { Authorization: 'Basic xxx', Random: 'random' }, + }); + + let modifiedHeaders; + reauthenticate.mockImplementation((request) => { + modifiedHeaders = request.headers; + return Promise.resolve(AuthenticationResult.notHandled()); + }); + + await unauthorizedErrorHandler( + { error: failureReason, request: mockRequest }, + mockUnauthorizedErrorToolkit + ); + + expect(reauthenticate).toHaveBeenCalledTimes(1); + expect(reauthenticate).toHaveBeenCalledWith(mockRequest); + expect(modifiedHeaders).toEqual({ Random: 'random' }); + + expect(mockRequest.headers).toEqual({ Authorization: 'Basic xxx', Random: 'random' }); + }); + + it('filters out and recovers `Authorization` header when provider can handle error.', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ) as UnauthorizedError; + + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { Authorization: 'Basic xxx', Random: 'random' }, + }); + + let modifiedHeaders; + reauthenticate.mockImplementation((request) => { + modifiedHeaders = request.headers; + return Promise.resolve( + AuthenticationResult.succeeded(mockAuthenticatedUser(), { + authHeaders: { header: 'value' }, + }) + ); + }); + + await unauthorizedErrorHandler( + { error: failureReason, request: mockRequest }, + mockUnauthorizedErrorToolkit + ); + + expect(reauthenticate).toHaveBeenCalledTimes(1); + expect(reauthenticate).toHaveBeenCalledWith(mockRequest); + expect(modifiedHeaders).toEqual({ Random: 'random' }); + + expect(mockRequest.headers).toEqual({ Authorization: 'Basic xxx', Random: 'random' }); + }); + + it('filters out and recovers `Authorization` header when provider fails with unexpected error.', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ) as UnauthorizedError; + + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { Authorization: 'Basic xxx', Random: 'random' }, + }); + + let modifiedHeaders; + reauthenticate.mockImplementation((request) => { + modifiedHeaders = request.headers; + return Promise.reject(new Error('Uh oh.')); + }); + + await expect( + unauthorizedErrorHandler( + { error: failureReason, request: mockRequest }, + mockUnauthorizedErrorToolkit + ) + ).rejects.toThrow(new Error('Uh oh.')); + + expect(reauthenticate).toHaveBeenCalledTimes(1); + expect(reauthenticate).toHaveBeenCalledWith(mockRequest); + expect(modifiedHeaders).toEqual({ Random: 'random' }); + + expect(mockRequest.headers).toEqual({ Authorization: 'Basic xxx', Random: 'random' }); + }); + }); + describe('getServerBaseURL()', () => { let getServerBaseURL: () => string; beforeEach(() => { diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index d05260094df8c..84dc7522582fe 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -7,6 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { + ElasticsearchServiceSetup, HttpServiceSetup, HttpServiceStart, IClusterClient, @@ -15,9 +16,8 @@ import type { LoggerFactory, } from 'src/core/server'; +import type { AuthenticatedUser, SecurityLicense } from '../../common'; import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../common/constants'; -import type { SecurityLicense } from '../../common/licensing'; -import type { AuthenticatedUser } from '../../common/model'; import { shouldProviderUseLoginForm } from '../../common/model'; import type { AuditServiceSetup } from '../audit'; import type { ConfigType } from '../config'; @@ -35,6 +35,7 @@ import { renderUnauthenticatedPage } from './unauthenticated_page'; interface AuthenticationServiceSetupParams { http: Pick; + elasticsearch: Pick; config: ConfigType; license: SecurityLicense; buildNumber: number; @@ -87,7 +88,7 @@ export class AuthenticationService { constructor(private readonly logger: Logger) {} - setup({ config, http, license, buildNumber }: AuthenticationServiceSetupParams) { + setup({ config, http, license, buildNumber, elasticsearch }: AuthenticationServiceSetupParams) { this.license = license; // If we cannot automatically authenticate users we should redirect them straight to the login @@ -216,6 +217,73 @@ export class AuthenticationService { }, }); }); + + elasticsearch.setUnauthorizedErrorHandler(async ({ error, request }, toolkit) => { + if (!this.authenticator) { + this.logger.error('Authentication sub-system is not fully initialized yet.'); + return toolkit.notHandled(); + } + + if (!license.isLicenseAvailable() || !license.isEnabled()) { + this.logger.error( + `License is not available or does not support security features, re-authentication is not possible (available: ${license.isLicenseAvailable()}, enabled: ${license.isEnabled()}).` + ); + return toolkit.notHandled(); + } + + if (getErrorStatusCode(error) !== 401) { + this.logger.error( + `Re-authentication is not possible for the following error: ${getDetailedErrorMessage( + error + )}.` + ); + return toolkit.notHandled(); + } + + this.logger.warn(`Re-authenticating request due to error: ${getDetailedErrorMessage(error)}`); + + let authenticationResult; + const originalHeaders = request.headers; + try { + // WORKAROUND: Due to BWC reasons Core mutates headers of the original request with authentication + // headers returned during authentication stage. We should remove these headers before re-authentication to not + // conflict with the HTTP authentication logic. Performance impact is negligible since this is not a hot path. + (request.headers as Record) = Object.fromEntries( + Object.entries(originalHeaders).filter( + ([headerName]) => headerName.toLowerCase() !== 'authorization' + ) + ); + authenticationResult = await this.authenticator.reauthenticate(request); + } catch (err) { + this.logger.error( + `Re-authentication failed due to unexpected error: ${getDetailedErrorMessage(err)}.` + ); + throw err; + } finally { + (request.headers as Record) = originalHeaders; + } + + if (authenticationResult.succeeded()) { + if (authenticationResult.authHeaders) { + this.logger.debug('Re-authentication succeeded'); + return toolkit.retry({ authHeaders: authenticationResult.authHeaders }); + } + + this.logger.error( + 'Re-authentication succeeded, but authentication headers are not available.' + ); + } else if (authenticationResult.failed()) { + this.logger.error( + `Re-authentication failed due to: ${getDetailedErrorMessage(authenticationResult.error)}` + ); + } else if (authenticationResult.redirected()) { + this.logger.error('Re-authentication failed since redirect is required.'); + } else { + this.logger.error('Re-authentication cannot be handled.'); + } + + return toolkit.notHandled(); + }); } start({ diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 0ad77898b6f14..62ca6168584fb 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -10,7 +10,7 @@ jest.mock('./providers/token'); jest.mock('./providers/saml'); jest.mock('./providers/http'); -import Boom from '@hapi/boom'; +import { errors } from '@elastic/elasticsearch'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { @@ -30,6 +30,7 @@ import { mockAuthenticatedUser } from '../../common/model/authenticated_user.moc import { auditServiceMock } from '../audit/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { securityMock } from '../mocks'; import type { SessionValue } from '../session_management'; import { sessionMock } from '../session_management/index.mock'; import { AuthenticationResult } from './authentication_result'; @@ -1327,13 +1328,16 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.failed(Boom.unauthorized()) + AuthenticationResult.failed(failureReason) ); mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) + AuthenticationResult.failed(failureReason) ); expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1); @@ -1348,13 +1352,16 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.failed(Boom.unauthorized()) + AuthenticationResult.failed(failureReason) ); mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) + AuthenticationResult.failed(failureReason) ); expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1); @@ -1818,6 +1825,184 @@ describe('Authenticator', () => { }); }); + describe('`reauthenticate` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + let mockSessVal: SessionValue; + const auditLogger = { + log: jest.fn(), + }; + + beforeEach(() => { + auditLogger.log.mockClear(); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + mockOptions.session.get.mockResolvedValue(null); + mockOptions.audit.asScoped.mockReturnValue(auditLogger); + mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); + + authenticator = new Authenticator(mockOptions); + }); + + it('fails if request is not provided.', async () => { + await expect(authenticator.reauthenticate(undefined as any)).rejects.toThrowError( + 'Request should be a valid "KibanaRequest" instance, was [undefined].' + ); + }); + + it('does not try to reauthenticate request if session is not available.', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect(authenticator.reauthenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); + + expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector even if it is enabled if session is not available.', async () => { + const request = httpServerMock.createKibanaRequest(); + + authenticator = new Authenticator( + getMockOptions({ + selector: { enabled: true }, + providers: { basic: { basic1: { order: 0 } } }, + }) + ); + + await expect(authenticator.reauthenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); + + expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); + }); + + it('does not clear session if provider cannot handle authentication', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.notHandled() + ); + + await expect(authenticator.reauthenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); + + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.authenticate).toBeCalledWith( + request, + mockSessVal.state + ); + }); + + it('does not clear session if authentication fails with non-401 reason.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const failureReason = new Error('some error'); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.reauthenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); + + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); + }); + + it('extends session if no update is needed.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.reauthenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockOptions.session.extend).toHaveBeenCalledTimes(1); + expect(mockOptions.session.extend).toHaveBeenCalledWith(request, mockSessVal); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); + }); + + it('replaces existing session with the one returned by authentication provider', async () => { + const user = mockAuthenticatedUser(); + const newState = { authorization: 'Basic yyy' }; + const request = httpServerMock.createKibanaRequest(); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: newState }) + ); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.reauthenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: newState }) + ); + + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { + ...mockSessVal, + state: newState, + }); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); + }); + + it('clears session if provider failed to authenticate request with 401.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 401, body: {} }) + ); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.reauthenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); + + expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.session.invalidate).toHaveBeenCalledWith(request, { match: 'current' }); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: { action: 'user_logout', category: ['authentication'], outcome: 'unknown' }, + }) + ); + }); + }); + describe('`logout` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index cf81b7a311ba4..8335747902e6c 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -406,6 +406,34 @@ export class Authenticator { return AuthenticationResult.notHandled(); } + /** + * Tries to reauthenticate request with the existing session. + * @param request Request instance. + */ + async reauthenticate(request: KibanaRequest) { + assertRequest(request); + + const existingSessionValue = await this.getSessionValue(request); + if (!existingSessionValue) { + this.logger.warn('It is not possible to extend session since it is no longer available.'); + return AuthenticationResult.notHandled(); + } + + // We can ignore `undefined` value here since it's ruled out on the previous step, if provider isn't + // available then `getSessionValue` should have returned `null`. + const provider = this.providers.get(existingSessionValue.provider.name)!; + const authenticationResult = await provider.authenticate(request, existingSessionValue.state); + if (!authenticationResult.notHandled()) { + await this.updateSessionValue(request, { + provider: existingSessionValue.provider, + authenticationResult, + existingSessionValue, + }); + } + + return authenticationResult; + } + /** * Deauthenticates current request. * @param request Request instance. diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 36be02138289a..b02f852551948 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -242,6 +242,7 @@ export class SecurityPlugin this.sessionManagementService.setup({ config, http: core.http, taskManager }); this.authenticationService.setup({ http: core.http, + elasticsearch: core.elasticsearch, config, license, buildNumber: this.initializerContext.env.packageInfo.buildNum, diff --git a/x-pack/test/security_api_integration/kerberos.config.ts b/x-pack/test/security_api_integration/kerberos.config.ts index 7dba77e61999e..5ac3a8c60ee3a 100644 --- a/x-pack/test/security_api_integration/kerberos.config.ts +++ b/x-pack/test/security_api_integration/kerberos.config.ts @@ -15,6 +15,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const kerberosKeytabPath = resolve(__dirname, './fixtures/kerberos/krb5.keytab'); const kerberosConfigPath = resolve(__dirname, './fixtures/kerberos/krb5.conf'); + const testEndpointsPlugin = resolve( + __dirname, + '../security_functional/fixtures/common/test_endpoints' + ); + return { testFiles: [require.resolve('./tests/kerberos')], servers: xPackAPITestsConfig.get('servers'), @@ -42,6 +47,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${testEndpointsPlugin}`, `--xpack.security.authc.providers=${JSON.stringify(['kerberos', 'basic'])}`, ], }, diff --git a/x-pack/test/security_api_integration/oidc.config.ts b/x-pack/test/security_api_integration/oidc.config.ts index b2822a49b2042..6c4982989654a 100644 --- a/x-pack/test/security_api_integration/oidc.config.ts +++ b/x-pack/test/security_api_integration/oidc.config.ts @@ -15,6 +15,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); const jwksPath = resolve(__dirname, './fixtures/oidc/jwks.json'); + const testEndpointsPlugin = resolve( + __dirname, + '../security_functional/fixtures/common/test_endpoints' + ); + return { testFiles: [require.resolve('./tests/oidc/authorization_code_flow')], servers: xPackAPITestsConfig.get('servers'), @@ -50,6 +55,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), `--plugin-path=${plugin}`, + `--plugin-path=${testEndpointsPlugin}`, `--xpack.security.authProviders=${JSON.stringify(['oidc', 'basic'])}`, '--xpack.security.authc.oidc.realm="oidc1"', ], diff --git a/x-pack/test/security_api_integration/pki.config.ts b/x-pack/test/security_api_integration/pki.config.ts index d920a4375753c..46e20f009e4c3 100644 --- a/x-pack/test/security_api_integration/pki.config.ts +++ b/x-pack/test/security_api_integration/pki.config.ts @@ -13,6 +13,11 @@ import { services } from './services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + const testEndpointsPlugin = resolve( + __dirname, + '../security_functional/fixtures/common/test_endpoints' + ); + const servers = { ...xPackAPITestsConfig.get('servers'), elasticsearch: { @@ -54,6 +59,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${testEndpointsPlugin}`, '--server.ssl.enabled=true', `--server.ssl.key=${KBN_KEY_PATH}`, `--server.ssl.certificate=${KBN_CERT_PATH}`, diff --git a/x-pack/test/security_api_integration/saml.config.ts b/x-pack/test/security_api_integration/saml.config.ts index c8de50008c3bb..e3597813d17d6 100644 --- a/x-pack/test/security_api_integration/saml.config.ts +++ b/x-pack/test/security_api_integration/saml.config.ts @@ -15,6 +15,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); const idpPath = resolve(__dirname, './fixtures/saml/idp_metadata.xml'); + const testEndpointsPlugin = resolve( + __dirname, + '../security_functional/fixtures/common/test_endpoints' + ); + return { testFiles: [require.resolve('./tests/saml')], servers: xPackAPITestsConfig.get('servers'), @@ -44,6 +49,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${testEndpointsPlugin}`, `--xpack.security.authc.providers=${JSON.stringify(['saml', 'basic'])}`, '--xpack.security.authc.saml.realm=saml1', '--xpack.security.authc.saml.maxRedirectURLSize=100b', diff --git a/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts b/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts index 84673c7b68f2e..808a9de0530d1 100644 --- a/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts +++ b/x-pack/test/security_api_integration/tests/kerberos/kerberos_login.ts @@ -374,6 +374,55 @@ export default function ({ getService }: FtrProviderContext) { expect(nonAjaxResponse.headers['www-authenticate']).to.be(undefined); }); + + describe('post-authentication stage', () => { + for (const client of ['start-contract', 'request-context', 'custom']) { + it(`expired access token should be automatically refreshed by the ${client} client`, async function () { + this.timeout(60000); + + // Access token expiration is set to 15s for API integration tests. + // Let's tell test endpoint to wait 30s after authentication and try to make a request to Elasticsearch + // triggering token refresh logic. + const response = await supertest + .post('/authentication/slow/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200); + + const newSessionCookies = response.headers['set-cookie']; + expect(newSessionCookies).to.have.length(1); + + const refreshedCookie = parseCookie(newSessionCookies[0])!; + checkCookieIsSet(refreshedCookie); + + // The second new cookie with fresh pair of access and refresh tokens should work. + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', refreshedCookie.cookieString()) + .expect(200); + + expect(response.headers['www-authenticate']).to.be(undefined); + }); + + it(`expired access token should be automatically refreshed by the ${client} client even for multiple concurrent requests`, async function () { + this.timeout(60000); + + // Send 5 concurrent requests with a cookie that contains an expired access token. + await Promise.all( + Array.from({ length: 5 }).map((value, index) => + supertest + .post(`/authentication/slow/me?a=${index}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200) + ) + ); + }); + } + }); }); describe('API access with missing access token document or expired refresh token.', () => { diff --git a/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts b/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts index eb12d4240a372..ea87ba551f9b8 100644 --- a/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts +++ b/x-pack/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts @@ -540,6 +540,53 @@ export default function ({ getService }: FtrProviderContext) { .set('Cookie', secondNewCookie.cookieString()) .expect(200); }); + + describe('post-authentication stage', () => { + for (const client of ['start-contract', 'request-context', 'custom']) { + it(`expired access token should be automatically refreshed by the ${client} client`, async function () { + this.timeout(60000); + + // Access token expiration is set to 15s for API integration tests. + // Let's tell test endpoint to wait 30s after authentication and try to make a request to Elasticsearch + // triggering token refresh logic. + const response = await supertest + .post('/authentication/slow/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200); + + const newSessionCookies = response.headers['set-cookie']; + expect(newSessionCookies).to.have.length(1); + + const newSessionCookie = parseCookie(newSessionCookies[0])!; + expectNewSessionCookie(newSessionCookie); + + // The second new cookie with fresh pair of access and refresh tokens should work. + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', newSessionCookie.cookieString()) + .expect(200); + }); + + it(`expired access token should be automatically refreshed by the ${client} client even for multiple concurrent requests`, async function () { + this.timeout(60000); + + // Send 5 concurrent requests with a cookie that contains an expired access token. + await Promise.all( + Array.from({ length: 5 }).map((value, index) => + supertest + .post(`/authentication/slow/me?a=${index}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200) + ) + ); + }); + } + }); }); describe('API access with missing access token document.', () => { diff --git a/x-pack/test/security_api_integration/tests/pki/pki_auth.ts b/x-pack/test/security_api_integration/tests/pki/pki_auth.ts index d6f47b06f4612..43a3acaee5d05 100644 --- a/x-pack/test/security_api_integration/tests/pki/pki_auth.ts +++ b/x-pack/test/security_api_integration/tests/pki/pki_auth.ts @@ -399,6 +399,59 @@ export default function ({ getService }: FtrProviderContext) { const refreshedCookie = parseCookie(cookies[0])!; checkCookieIsSet(refreshedCookie); }); + + describe('post-authentication stage', () => { + for (const client of ['start-contract', 'request-context', 'custom']) { + it(`expired access token should be automatically refreshed by the ${client} client`, async function () { + this.timeout(60000); + + // Access token expiration is set to 15s for API integration tests. + // Let's tell test endpoint to wait 30s after authentication and try to make a request to Elasticsearch + // triggering token refresh logic. + const response = await supertest + .post('/authentication/slow/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200); + + const newSessionCookies = response.headers['set-cookie']; + expect(newSessionCookies).to.have.length(1); + + const refreshedCookie = parseCookie(newSessionCookies[0])!; + checkCookieIsSet(refreshedCookie); + + // The second new cookie with fresh pair of access and refresh tokens should work. + await supertest + .get('/internal/security/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', refreshedCookie.cookieString()) + .expect(200); + }); + + it(`expired access token should be automatically refreshed by the ${client} client even for multiple concurrent requests`, async function () { + this.timeout(60000); + + // Send 5 concurrent requests with a cookie that contains an expired access token. + await Promise.all( + Array.from({ length: 5 }).map((value, index) => + supertest + .post(`/authentication/slow/me?a=${index}`) + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200) + ) + ); + }); + } + }); }); }); } diff --git a/x-pack/test/security_api_integration/tests/saml/saml_login.ts b/x-pack/test/security_api_integration/tests/saml/saml_login.ts index c0ea296297fe6..4d0c0c5c56847 100644 --- a/x-pack/test/security_api_integration/tests/saml/saml_login.ts +++ b/x-pack/test/security_api_integration/tests/saml/saml_login.ts @@ -447,8 +447,6 @@ export default function ({ getService }: FtrProviderContext) { let sessionCookie: Cookie; beforeEach(async function () { - this.timeout(40000); - const handshakeResponse = await supertest .get( '/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad' @@ -465,10 +463,6 @@ export default function ({ getService }: FtrProviderContext) { .expect(302); sessionCookie = parseCookie(samlAuthenticationResponse.headers['set-cookie'][0])!; - - // Access token expiration is set to 15s for API integration tests. - // Let's wait for 20s to make sure token expires. - await setTimeoutAsync(20000); }); const expectNewSessionCookie = (cookie: Cookie) => { @@ -479,7 +473,13 @@ export default function ({ getService }: FtrProviderContext) { expect(cookie.value).to.not.be(sessionCookie.value); }; - it('expired access token should be automatically refreshed', async () => { + it('expired access token should be automatically refreshed', async function () { + this.timeout(60000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await setTimeoutAsync(20000); + // This api call should succeed and automatically refresh token. Returned cookie will contain // the new access and refresh token pair. const firstResponse = await supertest @@ -525,7 +525,13 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); }); - it('should refresh access token even if multiple concurrent requests try to refresh it', async () => { + it('should refresh access token even if multiple concurrent requests try to refresh it', async function () { + this.timeout(60000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await setTimeoutAsync(20000); + // Send 5 concurrent requests with a cookie that contains an expired access token. await Promise.all( Array.from({ length: 5 }).map((value, index) => @@ -537,6 +543,54 @@ export default function ({ getService }: FtrProviderContext) { ) ); }); + + describe('post-authentication stage', () => { + for (const client of ['start-contract', 'request-context', 'custom']) { + it(`expired access token should be automatically refreshed by the ${client} client`, async function () { + this.timeout(60000); + + // Access token expiration is set to 15s for API integration tests. + // Let's tell test endpoint to wait 30s after authentication and try to make a request to Elasticsearch + // triggering token refresh logic. + const response = await supertest + .post('/authentication/slow/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200); + + const newSessionCookies = response.headers['set-cookie']; + expect(newSessionCookies).to.have.length(1); + + const newSessionCookie = parseCookie(newSessionCookies[0])!; + expectNewSessionCookie(newSessionCookie); + await checkSessionCookie(newSessionCookie); + + // The second new cookie with fresh pair of access and refresh tokens should work. + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', newSessionCookie.cookieString()) + .expect(200); + }); + + it(`expired access token should be automatically refreshed by the ${client} client even for multiple concurrent requests`, async function () { + this.timeout(60000); + + // Send 5 concurrent requests with a cookie that contains an expired access token. + await Promise.all( + Array.from({ length: 5 }).map((value, index) => + supertest + .post(`/authentication/slow/me?a=${index}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200) + ) + ); + }); + } + }); }); describe('API access with missing access token document.', () => { diff --git a/x-pack/test/security_api_integration/tests/token/session.ts b/x-pack/test/security_api_integration/tests/token/session.ts index b668108b9ee8b..f42d9cc93585f 100644 --- a/x-pack/test/security_api_integration/tests/token/session.ts +++ b/x-pack/test/security_api_integration/tests/token/session.ts @@ -131,6 +131,57 @@ export default function ({ getService }: FtrProviderContext) { .set('Cookie', secondNewCookie.cookieString()) .expect(200); }); + + describe('post-authentication stage', () => { + for (const client of ['start-contract', 'request-context', 'custom']) { + it(`expired access token should be automatically refreshed by the ${client} client`, async function () { + this.timeout(60000); + + const sessionCookie = await createSessionCookie(); + + // Access token expiration is set to 15s for API integration tests. + // Let's tell test endpoint to wait 30s after authentication and try to make a request to Elasticsearch + // triggering token refresh logic. + const response = await supertest + .post('/authentication/slow/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200); + + const newSessionCookies = response.headers['set-cookie']; + expect(newSessionCookies).to.have.length(1); + + const newSessionCookie = parseCookie(newSessionCookies[0])!; + expectNewSessionCookie(sessionCookie, newSessionCookie); + + // The second new cookie with fresh pair of access and refresh tokens should work. + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', newSessionCookie.cookieString()) + .expect(200); + }); + + it(`expired access token should be automatically refreshed by the ${client} client even for multiple concurrent requests`, async function () { + this.timeout(60000); + + const sessionCookie = await createSessionCookie(); + + // Send 5 concurrent requests with a cookie that contains an expired access token. + await Promise.all( + Array.from({ length: 5 }).map((value, index) => + supertest + .post(`/authentication/slow/me?a=${index}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ duration: '30s', client }) + .expect(200) + ) + ); + }); + } + }); }); describe('API access with missing access token document.', () => { diff --git a/x-pack/test/security_api_integration/token.config.ts b/x-pack/test/security_api_integration/token.config.ts index 54efd77ca8ae9..d0ba54de7fc54 100644 --- a/x-pack/test/security_api_integration/token.config.ts +++ b/x-pack/test/security_api_integration/token.config.ts @@ -6,11 +6,17 @@ */ import { FtrConfigProviderContext } from '@kbn/test'; +import { resolve } from 'path'; import { services } from './services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + const testEndpointsPlugin = resolve( + __dirname, + '../security_functional/fixtures/common/test_endpoints' + ); + return { testFiles: [require.resolve('./tests/token')], servers: xPackAPITestsConfig.get('servers'), @@ -33,6 +39,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${testEndpointsPlugin}`, '--xpack.security.authc.providers=["token"]', ], }, diff --git a/x-pack/test/security_functional/fixtures/common/test_endpoints/server/index.ts b/x-pack/test/security_functional/fixtures/common/test_endpoints/server/index.ts index 5db4a25036eca..d06a8c1059b49 100644 --- a/x-pack/test/security_functional/fixtures/common/test_endpoints/server/index.ts +++ b/x-pack/test/security_functional/fixtures/common/test_endpoints/server/index.ts @@ -8,8 +8,8 @@ import { PluginInitializer, Plugin } from '../../../../../../../src/core/server'; import { initRoutes } from './init_routes'; -export const plugin: PluginInitializer = (): Plugin => ({ - setup: (core) => initRoutes(core), +export const plugin: PluginInitializer = (initializerContext): Plugin => ({ + setup: (core) => initRoutes(initializerContext, core), start: () => {}, stop: () => {}, }); diff --git a/x-pack/test/security_functional/fixtures/common/test_endpoints/server/init_routes.ts b/x-pack/test/security_functional/fixtures/common/test_endpoints/server/init_routes.ts index 378ab8a90c29a..9ca600970cb86 100644 --- a/x-pack/test/security_functional/fixtures/common/test_endpoints/server/init_routes.ts +++ b/x-pack/test/security_functional/fixtures/common/test_endpoints/server/init_routes.ts @@ -6,9 +6,12 @@ */ import { schema } from '@kbn/config-schema'; -import { CoreSetup } from '../../../../../../../src/core/server'; +import { errors } from '@elastic/elasticsearch'; +import { CoreSetup, PluginInitializerContext } from '../../../../../../../src/core/server'; + +export function initRoutes(initializerContext: PluginInitializerContext, core: CoreSetup) { + const logger = initializerContext.logger.get(); -export function initRoutes(core: CoreSetup) { const authenticationAppOptions = { simulateUnauthorized: false }; core.http.resources.register( { @@ -35,4 +38,62 @@ export function initRoutes(core: CoreSetup) { return response.ok(); } ); + + router.post( + { + path: '/authentication/slow/me', + validate: { + body: schema.object({ + duration: schema.duration(), + client: schema.oneOf([ + schema.literal('request-context'), + schema.literal('start-contract'), + schema.literal('custom'), + ]), + }), + }, + options: { xsrfRequired: false }, + }, + async (context, request, response) => { + const slowLog = logger.get('slow/me'); + slowLog.info(`Received request ${JSON.stringify(request.body)}.`); + + let scopedClient; + if (request.body.client === 'start-contract') { + scopedClient = (await core.getStartServices())[0].elasticsearch.client.asScoped(request); + } else if (request.body.client === 'request-context') { + scopedClient = context.core.elasticsearch.client; + } else { + scopedClient = (await core.getStartServices())[0].elasticsearch + .createClient('custom') + .asScoped(request); + } + + await scopedClient.asCurrentUser.security.authenticate(); + slowLog.info( + `Performed initial authentication request, waiting (${request.body.duration.asSeconds()}s)...` + ); + + // 2. Wait specified amount of time. + await new Promise((resolve) => setTimeout(resolve, request.body.duration.asMilliseconds())); + slowLog.info(`Waiting is done, performing final authentication request.`); + + // 3. Make authentication request once again and return result. + try { + const { body } = await scopedClient.asCurrentUser.security.authenticate(); + slowLog.info( + `Successfully performed final authentication request: ${JSON.stringify(body)}` + ); + return response.ok({ body }); + } catch (err) { + slowLog.error( + `Failed to perform final authentication request: ${ + err instanceof errors.ResponseError ? JSON.stringify(err.body) : err.message + }` + ); + + throw err; + } + } + ); } From 9bb894e8f0340da4c3431dd1af84214e8e5264b5 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 25 Jan 2022 09:48:27 +0100 Subject: [PATCH 2/2] Review#1: improve log messages, do not use `warn` log level for expected scenarios. --- .../security/server/authentication/authentication_service.ts | 4 +++- .../plugins/security/server/authentication/authenticator.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index 84dc7522582fe..96dcf23af4fd0 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -240,7 +240,9 @@ export class AuthenticationService { return toolkit.notHandled(); } - this.logger.warn(`Re-authenticating request due to error: ${getDetailedErrorMessage(error)}`); + this.logger.debug( + `Re-authenticating request due to error: ${getDetailedErrorMessage(error)}` + ); let authenticationResult; const originalHeaders = request.headers; diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 8335747902e6c..fd1de8af4a149 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -415,7 +415,7 @@ export class Authenticator { const existingSessionValue = await this.getSessionValue(request); if (!existingSessionValue) { - this.logger.warn('It is not possible to extend session since it is no longer available.'); + this.logger.warn('Session is no longer available and cannot be re-authenticated.'); return AuthenticationResult.notHandled(); }