diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 33a98127aa630..fcc232345a802 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -18,6 +18,7 @@ */ import { Request } from 'hapi'; import { merge } from 'lodash'; +import { Socket } from 'net'; import querystring from 'querystring'; @@ -37,6 +38,7 @@ interface RequestFixtureOptions { query?: Record; path?: string; method?: RouteMethod; + socket?: Socket; } function createKibanaRequestMock({ @@ -46,6 +48,7 @@ function createKibanaRequestMock({ body = {}, query = {}, method = 'get', + socket = new Socket(), }: RequestFixtureOptions = {}) { const queryString = querystring.stringify(query); return KibanaRequest.from( @@ -63,7 +66,7 @@ function createKibanaRequestMock({ }, route: { settings: {} }, raw: { - req: {}, + req: { socket }, }, } as any, { diff --git a/x-pack/legacy/server/lib/esjs_shield_plugin.js b/x-pack/legacy/server/lib/esjs_shield_plugin.js index 83f050ee5ce59..e0a69b5ce538e 100644 --- a/x-pack/legacy/server/lib/esjs_shield_plugin.js +++ b/x-pack/legacy/server/lib/esjs_shield_plugin.js @@ -536,5 +536,21 @@ fmt: '/_security/api_key', }, }); + + /** + * 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/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index f01047eff7aa3..b3f34b023d8ee 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -25,6 +25,7 @@ import { SAMLAuthenticationProvider, TokenAuthenticationProvider, OIDCAuthenticationProvider, + PKIAuthenticationProvider, isSAMLRequestQuery, } from './providers'; import { AuthenticationResult } from './authentication_result'; @@ -98,6 +99,7 @@ const providerMap = new Map< ['saml', SAMLAuthenticationProvider], ['token', TokenAuthenticationProvider], ['oidc', OIDCAuthenticationProvider], + ['pki', PKIAuthenticationProvider], ]); function assertRequest(request: KibanaRequest) { 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 8e7410ddec077..a659786f4aeff 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -7,12 +7,20 @@ import sinon from 'sinon'; import { ScopedClusterClient } from '../../../../../../src/core/server'; import { Tokens } from '../tokens'; -import { loggingServiceMock, httpServiceMock } from '../../../../../../src/core/server/mocks'; +import { + loggingServiceMock, + httpServiceMock, + elasticsearchServiceMock, +} from '../../../../../../src/core/server/mocks'; export type MockAuthenticationProviderOptions = ReturnType< typeof mockAuthenticationProviderOptions >; +export type MockAuthenticationProviderOptionsWithJest = ReturnType< + typeof mockAuthenticationProviderOptionsWithJest +>; + export function mockScopedClusterClient( client: MockAuthenticationProviderOptions['client'], requestMatcher: sinon.SinonMatcher = sinon.match.any @@ -34,3 +42,16 @@ export function mockAuthenticationProviderOptions() { tokens: sinon.createStubInstance(Tokens), }; } + +// Will be renamed to mockAuthenticationProviderOptions as soon as we migrate all providers tests to Jest. +export function mockAuthenticationProviderOptionsWithJest() { + const basePath = httpServiceMock.createSetupContract().basePath; + basePath.get.mockReturnValue('/base-path'); + + return { + client: elasticsearchServiceMock.createClusterClient(), + logger: loggingServiceMock.create().get(), + basePath, + tokens: { refresh: jest.fn(), invalidate: jest.fn() }, + }; +} diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index fef3be16c8d91..af0b90766e859 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -14,3 +14,4 @@ export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider, isSAMLRequestQuery } from './saml'; export { TokenAuthenticationProvider } from './token'; export { OIDCAuthenticationProvider, OIDCAuthenticationFlow } from './oidc'; +export { PKIAuthenticationProvider } from './pki'; diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts new file mode 100644 index 0000000000000..35d827c3a9bd1 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -0,0 +1,589 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('net'); +jest.mock('tls'); + +import { PeerCertificate, TLSSocket } from 'tls'; +import { errors } from 'elasticsearch'; + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { + MockAuthenticationProviderOptionsWithJest, + mockAuthenticationProviderOptionsWithJest, +} from './base.mock'; + +import { PKIAuthenticationProvider } from './pki'; +import { + ElasticsearchErrorHelpers, + ScopedClusterClient, +} from '../../../../../../src/core/server/elasticsearch'; +import { Socket } from 'net'; +import { getErrorStatusCode } from '../../errors'; + +interface MockPeerCertificate extends Partial { + issuerCertificate: MockPeerCertificate; + fingerprint256: string; +} + +function getMockPeerCertificate(chain: string[] | string) { + const mockPeerCertificate = {} as MockPeerCertificate; + + (Array.isArray(chain) ? chain : [chain]).reduce( + (certificate, fingerprint, index, fingerprintChain) => { + certificate.fingerprint256 = fingerprint; + certificate.raw = { toString: (enc: string) => `fingerprint:${fingerprint}:${enc}` }; + + // Imitate self-signed certificate that is issuer for itself. + certificate.issuerCertificate = index === fingerprintChain.length - 1 ? certificate : {}; + + return certificate.issuerCertificate; + }, + mockPeerCertificate as Record + ); + + return mockPeerCertificate; +} + +function getMockSocket({ + authorized = false, + peerCertificate = null, +}: { + authorized?: boolean; + peerCertificate?: MockPeerCertificate | null; +} = {}) { + const socket = new TLSSocket(new Socket()); + socket.authorized = authorized; + socket.getPeerCertificate = jest.fn().mockReturnValue(peerCertificate); + return socket; +} + +describe('PKIAuthenticationProvider', () => { + let provider: PKIAuthenticationProvider; + let mockOptions: MockAuthenticationProviderOptionsWithJest; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptionsWithJest(); + provider = new PKIAuthenticationProvider(mockOptions); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('`authenticate` method', () => { + it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic some:credentials' }, + }); + const state = { + accessToken: 'some-valid-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }; + + const authenticationResult = await provider.authenticate(request, state); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle requests without certificate.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ authorized: true }), + }); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.notHandled()).toBe(true); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('does not handle unauthorized requests.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), + }); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.notHandled()).toBe(true); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ authorized: true }), + }); + + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + const authenticationResult = await provider.authenticate(request, state); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toMatchInlineSnapshot( + `[Error: Peer certificate is not available]` + ); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + const authenticationResult = await provider.authenticate(request, state); + + expect(authenticationResult.failed()).toBe(true); + expect(getErrorStatusCode(authenticationResult.error)).toBe(401); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + + it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), + }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + const authenticationResult = await provider.authenticate(request, state); + + expect(authenticationResult.failed()).toBe(true); + expect(getErrorStatusCode(authenticationResult.error)).toBe(401); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + + it('gets an access token in exchange to peer certificate chain and stores it in the state.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + headers: {}, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { + x509_certificate_chain: [ + 'fingerprint:2A:7A:C2:DD:base64', + 'fingerprint:3B:8B:D3:EE:base64', + ], + }, + }); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Bearer access-token` }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.state).toEqual({ + accessToken: 'access-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }); + }); + + it('gets an access token in exchange to a self-signed certificate and stores it in the state.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + headers: {}, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Bearer access-token` }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.state).toEqual({ + accessToken: 'access-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }); + }); + + it('invalidates existing token and gets a new one if fingerprints do not match.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); + const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '3A:9A:C5:DD' }; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + const authenticationResult = await provider.authenticate(request, state); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { + x509_certificate_chain: [ + 'fingerprint:2A:7A:C2:DD:base64', + 'fingerprint:3B:8B:D3:EE:base64', + ], + }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.state).toEqual({ + accessToken: 'access-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }); + }); + + it('gets a new access token even if existing token is expired.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); + const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser + // In response to call with an expired token. + .mockRejectedValueOnce(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())) + // In response to a call with a new token. + .mockResolvedValueOnce(user); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + const authenticationResult = await provider.authenticate(request, state); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { + x509_certificate_chain: [ + 'fingerprint:2A:7A:C2:DD:base64', + 'fingerprint:3B:8B:D3:EE:base64', + ], + }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.state).toEqual({ + accessToken: 'access-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }); + }); + + it('fails with 401 if existing token is expired, but certificate is not present.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + + const authenticationResult = await provider.authenticate(request, state); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(getErrorStatusCode(authenticationResult.error)).toBe(401); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + }); + + it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + }); + + it('fails if could not retrieve user using the new access token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: {}, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Bearer access-token` }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + }); + + it('succeeds if state contains a valid token.', async () => { + const user = mockAuthenticatedUser(); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256), + }), + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + + const authenticationResult = await provider.authenticate(request, state); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toEqual({ + authorization: `Bearer ${state.accessToken}`, + }); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBeUndefined(); + }); + + it('fails if token from the state is rejected because of unknown reason.', async () => { + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256), + }), + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(new errors.ServiceUnavailable()); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + + const authenticationResult = await provider.authenticate(request, state); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toHaveProperty('status', 503); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + }); + + it('succeeds if `authorization` contains a valid token.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-valid-token' }, + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + + const authenticationResult = await provider.authenticate(request); + + expect(request.headers.authorization).toBe('Bearer some-valid-token'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBeUndefined(); + }); + + it('fails if token from `authorization` header is rejected.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-invalid-token' }, + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + + const authenticationResult = await provider.authenticate(request); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { + const user = mockAuthenticatedUser(); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-invalid-token' }, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser + // In response to call with a token from header. + .mockRejectedValueOnce(failureReason) + // In response to a call with a token from session (not expected to be called). + .mockResolvedValueOnce(user); + mockOptions.client.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + + const authenticationResult = await provider.authenticate(request, state); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + }); + + describe('`logout` method', () => { + it('returns `notHandled` if state is not presented.', async () => { + const request = httpServerMock.createKibanaRequest(); + + let deauthenticateResult = await provider.logout(request); + expect(deauthenticateResult.notHandled()).toBe(true); + + deauthenticateResult = await provider.logout(request, null); + expect(deauthenticateResult.notHandled()).toBe(true); + + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('fails if `tokens.invalidate` fails', async () => { + const request = httpServerMock.createKibanaRequest(); + const state = { accessToken: 'foo', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + const failureReason = new Error('failed to delete token'); + mockOptions.tokens.invalidate.mockRejectedValue(failureReason); + + const authenticationResult = await provider.logout(request, state); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: 'foo' }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('redirects to `/logged_out` page if access token is invalidated successfully.', async () => { + const request = httpServerMock.createKibanaRequest(); + const state = { accessToken: 'foo', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + mockOptions.tokens.invalidate.mockResolvedValue(undefined); + + const authenticationResult = await provider.logout(request, state); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: 'foo' }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/logged_out'); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts new file mode 100644 index 0000000000000..788395feae442 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { DetailedPeerCertificate } from 'tls'; +import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { BaseAuthenticationProvider } from './base'; +import { Tokens } from '../tokens'; + +/** + * The state supported by the provider. + */ +interface ProviderState { + /** + * Access token we got in exchange to peer certificate chain. + */ + accessToken: string; + + /** + * The SHA-256 digest of the DER encoded peer leaf certificate. It is a `:` separated hexadecimal string. + */ + peerCertificateFingerprint256: string; +} + +/** + * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. + * @param request Request instance to extract authentication scheme for. + */ +function getRequestAuthenticationScheme(request: KibanaRequest) { + const authorization = request.headers.authorization; + if (!authorization || typeof authorization !== 'string') { + return ''; + } + + return authorization.split(/\s+/)[0].toLowerCase(); +} + +/** + * Provider that supports PKI request authentication. + */ +export class PKIAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Performs PKI request authentication. + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); + + const authenticationScheme = getRequestAuthenticationScheme(request); + if (authenticationScheme && authenticationScheme !== 'bearer') { + this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + return AuthenticationResult.notHandled(); + } + + let authenticationResult = AuthenticationResult.notHandled(); + if (authenticationScheme) { + // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. + authenticationResult = await this.authenticateWithBearerScheme(request); + } + + if (state && authenticationResult.notHandled()) { + authenticationResult = await this.authenticateViaState(request, state); + + // If access token expired or doesn't match to the certificate fingerprint we should try to get + // a new one in exchange to peer certificate chain. + if ( + authenticationResult.notHandled() || + (authenticationResult.failed() && + Tokens.isAccessTokenExpiredError(authenticationResult.error)) + ) { + authenticationResult = await this.authenticateViaPeerCertificate(request); + // If we have an active session that we couldn't use to authenticate user and at the same time + // we couldn't use peer's certificate to establish a new one, then we should respond with 401 + // and force authenticator to clear the session. + if (authenticationResult.notHandled()) { + return AuthenticationResult.failed(Boom.unauthorized()); + } + } + } + + // If we couldn't authenticate by means of all methods above, let's try to check if we can authenticate + // request using its peer certificate chain, otherwise just return authentication result we have. + return authenticationResult.notHandled() + ? await this.authenticateViaPeerCertificate(request) + : authenticationResult; + } + + /** + * Invalidates access token retrieved in exchange for peer certificate chain if it exists. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); + + if (!state) { + this.logger.debug('There is no access token to invalidate.'); + return DeauthenticationResult.notHandled(); + } + + try { + await this.options.tokens.invalidate({ accessToken: state.accessToken }); + } catch (err) { + this.logger.debug(`Failed invalidating access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } + + return DeauthenticationResult.redirectTo('/logged_out'); + } + + /** + * Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend. + * @param request Request instance. + */ + private async authenticateWithBearerScheme(request: KibanaRequest) { + this.logger.debug('Trying to authenticate request using "Bearer" authentication scheme.'); + + try { + const user = await this.getUser(request); + + this.logger.debug('Request has been authenticated using "Bearer" authentication scheme.'); + return AuthenticationResult.succeeded(user); + } catch (err) { + this.logger.debug( + `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to extract access token from state and adds it to the request before it's + * forwarded to Elasticsearch backend. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaState( + request: KibanaRequest, + { accessToken, peerCertificateFingerprint256 }: ProviderState + ) { + this.logger.debug('Trying to authenticate via state.'); + + // If peer is authorized, but its certificate isn't available, that likely means the connection + // with the peer is closed already. We shouldn't invalidate peer's access token in this case + // since we cannot guarantee that there is a mismatch in access token and peer certificate. + const peerCertificate = request.socket.getPeerCertificate(true); + if (peerCertificate === null && request.socket.authorized) { + this.logger.debug( + 'Cannot validate state access token with the peer certificate since it is not available.' + ); + return AuthenticationResult.failed(new Error('Peer certificate is not available')); + } + + if ( + !request.socket.authorized || + peerCertificate === null || + (peerCertificate as any).fingerprint256 !== peerCertificateFingerprint256 + ) { + this.logger.debug( + 'Peer certificate is not present or its fingerprint does not match to the one associated with the access token. Invalidating access token...' + ); + + try { + await this.options.tokens.invalidate({ accessToken }); + } catch (err) { + this.logger.debug(`Failed to invalidate access token: ${err.message}`); + return AuthenticationResult.failed(err); + } + + // Return "Not Handled" result to allow provider to try to exchange new peer certificate chain + // to the new access token down the line. + return AuthenticationResult.notHandled(); + } + + try { + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to exchange peer certificate chain to access/refresh token pair. + * @param request Request instance. + */ + private async authenticateViaPeerCertificate(request: KibanaRequest) { + this.logger.debug('Trying to authenticate request via peer certificate chain.'); + + if (!request.socket.authorized) { + this.logger.debug( + `Authentication is not possible since peer certificate was not authorized: ${request.socket.authorizationError}.` + ); + return AuthenticationResult.notHandled(); + } + + const peerCertificate = request.socket.getPeerCertificate(true); + if (peerCertificate === null) { + this.logger.debug('Authentication is not possible due to missing peer certificate chain.'); + return AuthenticationResult.notHandled(); + } + + // We should collect entire certificate chain as an ordered array of certificates encoded as base64 strings. + const certificateChain = this.getCertificateChain(peerCertificate); + let accessToken: string; + try { + accessToken = (await this.options.client.callAsInternalUser('shield.delegatePKI', { + body: { x509_certificate_chain: certificateChain }, + })).access_token; + } catch (err) { + this.logger.debug( + `Failed to exchange peer certificate chain to an access token: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + + this.logger.debug('Successfully retrieved access token in exchange to peer certificate chain.'); + + try { + // Then attempt to query for the user details using the new token + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); + + this.logger.debug('User has been authenticated with new access token'); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { + accessToken, + // NodeJS typings don't include `fingerprint256` yet. + peerCertificateFingerprint256: (peerCertificate as any).fingerprint256, + }, + }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via access token: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * 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). + * @param peerCertificate Peer leaf certificate instance. + */ + private getCertificateChain(peerCertificate: DetailedPeerCertificate | null) { + const certificateChain = []; + let certificate: DetailedPeerCertificate | null = peerCertificate; + while (certificate !== null && Object.keys(certificate).length > 0) { + certificateChain.push(certificate.raw.toString('base64')); + + // For self-signed certificates, `issuerCertificate` may be a circular reference. + if (certificate === certificate.issuerCertificate) { + this.logger.debug('Self-signed certificate is detected in certificate chain'); + certificate = null; + } else { + certificate = certificate.issuerCertificate; + } + } + + this.logger.debug( + `Peer certificate chain consists of ${certificateChain.length} certificates.` + ); + + return certificateChain; + } +} diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 222647e000672..f513d117e08ae 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -21,6 +21,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/token_api_integration/config.js'), require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), + // require.resolve('../test/pki_api_integration/config.ts'), require.resolve('../test/spaces_api_integration/spaces_only/config'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic'), diff --git a/x-pack/test/pki_api_integration/apis/index.ts b/x-pack/test/pki_api_integration/apis/index.ts new file mode 100644 index 0000000000000..d859ed172ac69 --- /dev/null +++ b/x-pack/test/pki_api_integration/apis/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('apis PKI', function() { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./security')); + }); +} diff --git a/x-pack/test/pki_api_integration/apis/security/index.ts b/x-pack/test/pki_api_integration/apis/security/index.ts new file mode 100644 index 0000000000000..d2bfe613ca7fa --- /dev/null +++ b/x-pack/test/pki_api_integration/apis/security/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('security', () => { + loadTestFile(require.resolve('./pki_auth')); + }); +} diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts new file mode 100644 index 0000000000000..8c29db674aaf3 --- /dev/null +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -0,0 +1,370 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import request, { Cookie } from 'request'; +import { delay } from 'bluebird'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +// @ts-ignore +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const CA_CERT = readFileSync(CA_CERT_PATH); +const FIRST_CLIENT_CERT = readFileSync(resolve(__dirname, '../../fixtures/first_client.p12')); +const SECOND_CLIENT_CERT = readFileSync(resolve(__dirname, '../../fixtures/second_client.p12')); +const UNTRUSTED_CLIENT_CERT = readFileSync( + resolve(__dirname, '../../fixtures/untrusted_client.p12') +); + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); + + function checkCookieIsSet(cookie: Cookie) { + expect(cookie.value).to.not.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(null); + } + + function checkCookieIsCleared(cookie: Cookie) { + expect(cookie.value).to.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(0); + } + + describe('PKI authentication', () => { + before(async () => { + await getService('esSupertest') + .post('/_security/role_mapping/first_client_pki') + .ca(CA_CERT) + .send({ + roles: ['kibana_user'], + enabled: true, + rules: { field: { dn: 'CN=first_client' } }, + }) + .expect(200); + }); + + it('should reject API requests that use untrusted certificate', async () => { + await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(UNTRUSTED_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .expect(401); + }); + + it('does not prevent basic login', async () => { + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + const response = await supertest + .post('/api/security/v1/login') + .ca(CA_CERT) + .pfx(UNTRUSTED_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ username, password }) + .expect(204); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const cookie = request.cookie(cookies[0])!; + checkCookieIsSet(cookie); + + const { body: user } = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(UNTRUSTED_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', cookie.cookieString()) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); + }); + + it('should properly set cookie and authenticate user', async () => { + const response = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + expect(response.body).to.eql({ + username: 'first_client', + roles: ['kibana_user'], + full_name: null, + email: null, + enabled: true, + metadata: { + pki_delegated_by_realm: 'reserved', + pki_delegated_by_user: 'elastic', + pki_dn: 'CN=first_client', + }, + authentication_realm: { name: 'pki1', type: 'pki' }, + lookup_realm: { name: 'pki1', type: 'pki' }, + }); + + // Cookie should be accepted. + await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + }); + + it('should update session if new certificate is provided', async () => { + let response = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + response = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(SECOND_CLIENT_CERT) + .set('Cookie', sessionCookie.cookieString()) + .expect(200, { + username: 'second_client', + roles: [], + full_name: null, + email: null, + enabled: true, + metadata: { + pki_delegated_by_realm: 'reserved', + pki_delegated_by_user: 'elastic', + pki_dn: 'CN=second_client', + }, + authentication_realm: { name: 'pki1', type: 'pki' }, + lookup_realm: { name: 'pki1', type: 'pki' }, + }); + + checkCookieIsSet(request.cookie(response.headers['set-cookie'][0])!); + }); + + it('should reject valid cookie if used with untrusted certificate', async () => { + const response = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(UNTRUSTED_CLIENT_CERT) + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + }); + + describe('API access with active session', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + }); + + it('should extend cookie on every successful non-system API call', async () => { + const apiResponseOne = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseOne.headers['set-cookie']).to.not.be(undefined); + const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0])!; + + checkCookieIsSet(sessionCookieOne); + expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); + + const apiResponseTwo = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseTwo.headers['set-cookie']).to.not.be(undefined); + const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0])!; + + checkCookieIsSet(sessionCookieTwo); + expect(sessionCookieTwo.value).to.not.equal(sessionCookieOne.value); + }); + + it('should not extend cookie for system API calls', async () => { + const systemAPIResponse = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('kbn-system-api', 'true') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(systemAPIResponse.headers['set-cookie']).to.be(undefined); + }); + + it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { + const apiResponse = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', 'Basic a3JiNTprcmI1') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('logging out', () => { + it('should redirect to `logged_out` page after successful logout', async () => { + // First authenticate user to retrieve session cookie. + const response = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(200); + + let cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // And then log user out. + const logoutResponse = await supertest + .get('/api/security/v1/logout') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + cookies = logoutResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(logoutResponse.headers.location).to.be('/logged_out'); + }); + + it('should redirect to home page if session cookie is not provided', async () => { + const logoutResponse = await supertest + .get('/api/security/v1/logout') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(302); + + expect(logoutResponse.headers['set-cookie']).to.be(undefined); + expect(logoutResponse.headers.location).to.be('/'); + }); + }); + + describe('API access with expired access token.', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + }); + + it('AJAX call should re-acquire token and update existing cookie', async function() { + this.timeout(40000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await delay(20000); + + // This api call should succeed and automatically refresh token. Returned cookie will contain + // the new access token. + const apiResponse = await supertest + .get('/api/security/v1/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + const cookies = apiResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const refreshedCookie = request.cookie(cookies[0])!; + checkCookieIsSet(refreshedCookie); + }); + + it('non-AJAX call should re-acquire token and update existing cookie', async function() { + this.timeout(40000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await delay(20000); + + // This request should succeed and automatically refresh token. Returned cookie will contain + // the new access and refresh token pair. + const nonAjaxResponse = await supertest + .get('/app/kibana') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + const cookies = nonAjaxResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const refreshedCookie = request.cookie(cookies[0])!; + checkCookieIsSet(refreshedCookie); + }); + }); + }); +} diff --git a/x-pack/test/pki_api_integration/config.ts b/x-pack/test/pki_api_integration/config.ts new file mode 100644 index 0000000000000..50b41ad251827 --- /dev/null +++ b/x-pack/test/pki_api_integration/config.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +// @ts-ignore +import { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } from '@kbn/dev-utils'; +import { services } from './services'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + + const servers = { + ...xPackAPITestsConfig.get('servers'), + elasticsearch: { + ...xPackAPITestsConfig.get('servers.elasticsearch'), + protocol: 'https', + }, + kibana: { + ...xPackAPITestsConfig.get('servers.kibana'), + protocol: 'https', + }, + }; + + return { + testFiles: [require.resolve('./apis')], + servers, + services, + junit: { + reportName: 'X-Pack PKI API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + ssl: true, + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + 'xpack.security.http.ssl.client_authentication=optional', + 'xpack.security.http.ssl.verification_mode=certificate', + 'xpack.security.authc.realms.native.native1.order=0', + 'xpack.security.authc.realms.pki.pki1.order=1', + 'xpack.security.authc.realms.pki.pki1.delegation.enabled=true', + `xpack.security.authc.realms.pki.pki1.certificate_authorities=${CA_CERT_PATH}`, + ], + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + '--server.ssl.enabled=true', + `--server.ssl.key=${ES_KEY_PATH}`, + `--server.ssl.certificate=${ES_CERT_PATH}`, + `--server.ssl.certificateAuthorities=${JSON.stringify([ + CA_CERT_PATH, + resolve(__dirname, './fixtures/kibana_ca.crt'), + ])}`, + `--server.ssl.clientAuthentication=required`, + `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + `--xpack.security.authc.providers=${JSON.stringify(['pki', 'basic'])}`, + ], + }, + }; +} diff --git a/x-pack/test/pki_api_integration/fixtures/README.md b/x-pack/test/pki_api_integration/fixtures/README.md new file mode 100644 index 0000000000000..0fcbc76183b48 --- /dev/null +++ b/x-pack/test/pki_api_integration/fixtures/README.md @@ -0,0 +1,7 @@ +# PKI Fixtures + +* `es_ca.key` - the CA key used to sign certificates from @kbn/dev-utils that are used and trusted by test Elasticsearch server. +* `first_client.p12` and `second_client.p12` - the client certificate bundles signed by `es_ca.key` and hence trusted by +both test Kibana and Elasticsearch servers. +* `untrusted_client.p12` - the client certificate bundle trusted by test Kibana server, but not test Elasticsearch test server. +* `kibana_ca.crt` and `kibana_ca.key` - the CA certificate and key trusted by test Kibana server only. \ No newline at end of file diff --git a/x-pack/test/pki_api_integration/fixtures/es_ca.key b/x-pack/test/pki_api_integration/fixtures/es_ca.key new file mode 100644 index 0000000000000..5428f86851e5a --- /dev/null +++ b/x-pack/test/pki_api_integration/fixtures/es_ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAjSJiqfwPZfvgHO1OZbxzgPn2EW/KewIHXygTAdL926Pm6R45 +G5H972B46NcSUoOZbOhDyvg6OKMJAICiXa85yOf3nyTo4APspR+K4AH60SEJohRF +mZwL/OryfiKvN5n5DxC2+Hb1wouwBUJM6DP62C24ve8YWuWwNkhJqWKe1YQUzPc1 +svqvU5uaHTzvLtp++RqSDNkcIqWl5S9Ip5PtOv6MHkCaIr2g4KQzplFwhT5qVd1Q +nYVBsQ0D8htLqUJBfjW0KHouEZpbjxJlc+EuyExS1o1+y3mVT+t2yZHAoIquh5ve +5A7a/RGJTyoR5u1DFs4Tcx2378kjA86gCQtClwIDAQABAoIBAFTOGKMzxrztQJmh +Lr6LIoyZpnaLygtoCK3xEprCAbB9KD9j3cTnUMMKIR0oPuY+FW8Pkczgo3ts2/fl +U6sfo4VJfc2vDA+vy/7cmUJJbkFDrNorfDb1QW7UbqnEhazPZIzc6lUahkpETZyb +XkMZGN3Ve3EFvojAA8ZaYYjarb52HRddLPZJ7c8ZiHfJ1jHNIvx6dIQ6CJVuovBJ +OGbbSAK8MjUtOI2XzWNHgUqGHcjVDFysuAac3ckK14TaN4KVNRl+usAMkZwqSM5u +j/ATFL9hx7nkzh3KWPsuOLMoLX7JN81z0YtT52wTxJoSiZKk/u91JHZ3NcrsOSPS +oLvVkyECgYEA16qtXvtmboAbqeuXf0nF+7QD0b+MdaRFIacqTG0LpEgY9Tjgs9Pn +6z44tHABWPVkRLNQZiky99MAq4Ci354Bk9dmylCw9ADH78VGmKWklbQEr1rw4dqm +DHTj9NQ79SyTdiasQjnnxCilWkrO6ZUqD8og4DT5MhzfxO/ZND8arGsCgYEAp4df +oI5lwlc1n9X/G9RQAKwNM5un8RmReleUVemjkcvWwvZVEjV0Gcc1WtjB+77Y5B9B +CM3laURDGrAgX5VS/I2jb0xqBNUr8XccSkDQAP9UuVPZgxpS+8d0R3fxVzniHWwR +WC2dW/Is40i/6+7AkFXhkiFiqxkvSg4pWHPazYUCgYB/gP7C+urSRZcVXJ3SuXD9 +oK3pYc/O9XGRtd0CFi4d0CpBQIFIj+27XKv1sYp6Z4oCO+k6nPzvG6Z3vrOMdUQF +fgHddttHRvbtwLo+ISAvCaEDc0aaoMQu9SSYaKmSB+qenbqV5NorVMR9n2C5JGEb +uKq7I1Z41C1Pp2XIx84jRQKBgQCjKvfZsjesZDJnfg9dtJlDPlARXt7gte16gkiI +sOnOfAGtnCzZclSlMuBlnk65enVXIpW+FIQH1iOhn7+4OQE92FpBceSk1ldZdJCK +RbwR7J5Bb0igJ4iBkA9R+KGIOmlgDLyL7MmiHyrXKCk9iynkqrDsGjY2vW3QrCBa +9WQ73QKBgQDAYZzplO4TPoPK9AnxoW/HpSwGEO7Fb8fLyPg94CvHn4QBCFJUKuTn +hBp/TJgF6CjQWQMr2FKVFF33Ow7+Qa96YGvmYlEjR/71D4Rlprj5JJpuO154DI3I +YIMNTjvwEQEI+YamMarKsz0Kq+I1EYSAf6bQ4H2PgxDxwTXaLkl0RA== +-----END RSA PRIVATE KEY----- diff --git a/x-pack/test/pki_api_integration/fixtures/first_client.p12 b/x-pack/test/pki_api_integration/fixtures/first_client.p12 new file mode 100644 index 0000000000000..62da80d9ab80e Binary files /dev/null and b/x-pack/test/pki_api_integration/fixtures/first_client.p12 differ diff --git a/x-pack/test/pki_api_integration/fixtures/kibana_ca.crt b/x-pack/test/pki_api_integration/fixtures/kibana_ca.crt new file mode 100644 index 0000000000000..eb57ad45e23dc --- /dev/null +++ b/x-pack/test/pki_api_integration/fixtures/kibana_ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCjCCAfKgAwIBAgIVAK8/CDsQxdAItvVPu2P72xXx4pbAMA0GCSqGSIb3DQEB +CwUAMBQxEjAQBgNVBAMTCUtpYmFuYSBDQTAeFw0xOTA4MTQxNTAwNDFaFw0yMjA4 +MTMxNTAwNDFaMBQxEjAQBgNVBAMTCUtpYmFuYSBDQTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANDBPAHZvBBtOZ/9aBHVmBFA3QS35wemnT2VwFE6LSUw +35Tj3/Vj/1NQAqAqKOUTCE0zQAyDBOGWAa1MadhYC2Fvxt/VUoOJWczeMuO3ktua +ybk3xzJJcOSoPjbPBUfQuRQ7GnBJsjyHKgPXIsP6wshQosYZnHPJcZSF1+6N9aGJ +psV/ukdLD8oJFq3pv7D9KY/gbAFeVkwWwdx9dqtfT0STGXOOZnLAz8ZmWH2WIt+f +t7+9EIv1pIUM6KOqANmhxxyitvka7XdN/ZEnwV/+Is9y/6N0NGaC9BWWoCNAgvuX +Ep0R+5qvNtCkL8okLaCc0a/B843e3k7eWuI8ES3Dhg0CAwEAAaNTMFEwHQYDVR0O +BBYEFBEp58Oz7rIAbT5O/yOGnSQcasG7MB8GA1UdIwQYMBaAFBEp58Oz7rIAbT5O +/yOGnSQcasG7MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAKZ/ +ZblM7pEOP77DLePM3NpjJQu73a7vjou2n0ifEq0HYsSMuKverZhhrc4L2PjRM34A +NVtcSsjnc2OkhtG6baV8q/GyDtvUXwnfCnI2MxNiVtmX7fWzHZVwkd4GCXnvOd3S +IBxzh4OYLV2rTFjo7oUWdDV+nFGVzQhhdlQ/fZ8by6g0qZvKKfe70Z3prmkRRRxz +QslJYQwB+cK3rdyAVJDYGbMGcJjM50PR3iM/PqQFAwOcyW9th1CpiHOOmbcQRmCS +W7h8A2TDzqvFWOz0QRoldt93vCXkP6PF3UXo2wpSPt8tzd6e0z0+HIyhYGUPstiE +zO36/AJiPQicgQK60gI= +-----END CERTIFICATE----- diff --git a/x-pack/test/pki_api_integration/fixtures/kibana_ca.key b/x-pack/test/pki_api_integration/fixtures/kibana_ca.key new file mode 100644 index 0000000000000..7065ce0ce398d --- /dev/null +++ b/x-pack/test/pki_api_integration/fixtures/kibana_ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0ME8Adm8EG05n/1oEdWYEUDdBLfnB6adPZXAUTotJTDflOPf +9WP/U1ACoCoo5RMITTNADIME4ZYBrUxp2FgLYW/G39VSg4lZzN4y47eS25rJuTfH +Mklw5Kg+Ns8FR9C5FDsacEmyPIcqA9ciw/rCyFCixhmcc8lxlIXX7o31oYmmxX+6 +R0sPygkWrem/sP0pj+BsAV5WTBbB3H12q19PRJMZc45mcsDPxmZYfZYi35+3v70Q +i/WkhQzoo6oA2aHHHKK2+Rrtd039kSfBX/4iz3L/o3Q0ZoL0FZagI0CC+5cSnRH7 +mq820KQvyiQtoJzRr8Hzjd7eTt5a4jwRLcOGDQIDAQABAoIBACZm5bsRat86uJcN +7s8ZE9hYrk/n5MArjlF98tr+cL+etgKVyOVDd/zDgzgjiVJapfRNsUKb95HoHnba +z73UtINAJL2YaI15/uMJHSN26bUsTF+eOy6tA++MY6WBf98uLl3iYYK2i+tGkhwS +v3p97scazlbS70z9ib9gv9BKnR0R+DTDBACwNtfurOGQh9PDU/e3orsrVBR/kj7o +nfjlXZzsuuVdGRHmO2yGoALCx1N0dMpO/ALWDi+phP5Jz6SBo0AGAKfC2tXZJrVz +qwHfCPnklIphHHmFkArmrAYZDOHBNtLRFQL8SmfcZOz3HO9er87ct/zAHN5im91s +vVZnYQECgYEA90u/q4Ux9A2iaJ4qfDqjM8i+DLezj0ogLLe9OZuBgKfy41bt7ilX +4iQ6cmmzq/9x0dM2ydRXTVJ0Ek/0EDgfVxxcWTRSHHrwy+WOnaB0hWkMzwgDak58 +fhRi5RAhXhCJlUyHsU7FBTYcwQE6I/C971X0AuCH06eEeaHG8HOERzkCgYEA2Bo2 +cYvRmfL7uh4STWTJkq08ppNDgo1aJ8brc+YtK3v11OZEbnSSmq0rdnDqkvFBmSrJ +wHprhs+RGBKBJzI3BMZ9uxOzhufk/xPZrpZie77JFvpzMSbC0WMTpb06QsGk1bu7 +jqtbNx8OYYF7PVxiuUsZY3sOIZ5b3t9yWeiFwXUCgYB8tcyRGPiaFQ4kKC9Qutl2 +0fNVwoZg6obTRk288XkbgpbwovQWOO9C8fYvoLKlOIsTv6pPmi/0pHI4ke2JCGR1 +r626prIJ/s3UZY3IXBSm+tUkyuu9/pq1kl5VGg9ZuolHq3J6rjiZajKR+qZxXYTL +X9NQaB7XVBFwrW7/76FzsQKBgCOOVIzkI22AFDjwP7SqM5xFkqgZrM7rMP1AdncQ +VThFYhJQfMvrtD9s5KzNMVtSBKgN6ToZKl35AveB++wWEAViH0fLmwtEVmI9wuA9 +8CBKKM32EUPyC7Xl5lKrys03DUb5Z4e23AA6xOP4KO3UqI2yNJAwrAeOBbGq9Cak +4nUNAoGBAKBhG767ePf/5fMrKJnsdxK2+fpP1UglHHTKOxwtLU9xtkUw96g7yzxj +5ma66yv1QuGIvAmbLVLCI6MmFvXYQmomoRt2KnYEgxjg8jjXXDdoOsNL87t7l0HA +CviL/UR7ZZV28rp5TmexRworu7hC4qer9NZxqV0a7bOLq0+uVda9 +-----END RSA PRIVATE KEY----- diff --git a/x-pack/test/pki_api_integration/fixtures/second_client.p12 b/x-pack/test/pki_api_integration/fixtures/second_client.p12 new file mode 100644 index 0000000000000..1c85686cb7b68 Binary files /dev/null and b/x-pack/test/pki_api_integration/fixtures/second_client.p12 differ diff --git a/x-pack/test/pki_api_integration/fixtures/untrusted_client.p12 b/x-pack/test/pki_api_integration/fixtures/untrusted_client.p12 new file mode 100644 index 0000000000000..1657afed2c58a Binary files /dev/null and b/x-pack/test/pki_api_integration/fixtures/untrusted_client.p12 differ diff --git a/x-pack/test/pki_api_integration/ftr_provider_context.d.ts b/x-pack/test/pki_api_integration/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..e3add3748f56d --- /dev/null +++ b/x-pack/test/pki_api_integration/ftr_provider_context.d.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/pki_api_integration/services.ts b/x-pack/test/pki_api_integration/services.ts new file mode 100644 index 0000000000000..31f58df081ddb --- /dev/null +++ b/x-pack/test/pki_api_integration/services.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + esSupertest: apiIntegrationServices.esSupertest, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +};