From 74210343a2436fdeac8076dd088d46972f061a04 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 4 Dec 2023 20:07:41 +0100 Subject: [PATCH] Allow using JWT credentials to grant API keys. (#172444) ## Summary In this PR we: * Allow using JWT credentials to grant API keys * Extend default value of `elasticsearch.requestHeadersWhitelist` to include both `authorization` and `es-client-authentication` to support JWT with required client authentication _by default_. See https://www.elastic.co/guide/en/elasticsearch/reference/8.11/jwt-auth-realm.html#jwt-realm-configuration * Add API integration tests for both JWTs with client authentication and without it __NOTE:__ We're not gating this functionality with the config flag (`xpack.security.authc.http.jwt.taggedRoutesOnly`) as we did for the Serverless offering. It'd be a breaking change as we already implicitly support JWT authentication without client authentication, and to be honest, it's not really necessary anyway. ## Testing Refer to the `Testing` section in this PR description: https://github.com/elastic/kibana/pull/159117. Or run already pre-configured Kibana functional test server: 1. `node scripts/functional_tests_server.js --config x-pack/test/security_api_integration/api_keys.config.ts` 2. Create a role mapping for JWT user: ```bash curl -X POST --location "http://localhost:9220/_security/role_mapping/jwt" \ -H "Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d "{ \"roles\": [ \"superuser\" ], \"enabled\": true, \"rules\": { \"all\": [{\"field\" : { \"realm.name\" : \"jwt_with_secret\" }}] } }" ``` 3. Send any Kibana API request with the following credentials: ```bash curl -X POST --location "xxxx" -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC8iLCJzdWIiOiJlbGFzdGljLWFnZW50IiwiYXVkIjoiZWxhc3RpY3NlYXJjaCIsIm5hbWUiOiJFbGFzdGljIEFnZW50IiwiaWF0Ijo5NDY2ODQ4MDAsImV4cCI6NDA3MDkwODgwMH0.P7RHKZlLskS5DfVRqoVO4ivoIq9rXl2-GW6hhC9NvTSkwphYivcjpTVcyENZvxTTvJJNqcyx6rF3T-7otTTIHBOZIMhZauc5dob-sqcN_mT2htqm3BpSdlJlz60TBq6diOtlNhV212gQCEJMPZj0MNj7kZRj_GsECrTaU7FU0A3HAzkbdx15vQJMKZiFbbQCVI7-X2J0bZzQKIWfMHD-VgHFwOe6nomT-jbYIXtCBDd6fNj1zTKRl-_uzjVqNK-h8YW1h6tE4xvZmXyHQ1-9yNKZIWC7iEaPkBLaBKQulLU5MvW3AtVDUhzm6--5H1J85JH5QhRrnKYRon7ZW5q1AQ' -H 'ES-Client-Authentication: SharedSecret my_super_secret' ....for example.... curl -X GET --location "http://localhost:5620/internal/security/me" \ -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC8iLCJzdWIiOiJlbGFzdGljLWFnZW50IiwiYXVkIjoiZWxhc3RpY3NlYXJjaCIsIm5hbWUiOiJFbGFzdGljIEFnZW50IiwiaWF0Ijo5NDY2ODQ4MDAsImV4cCI6NDA3MDkwODgwMH0.P7RHKZlLskS5DfVRqoVO4ivoIq9rXl2-GW6hhC9NvTSkwphYivcjpTVcyENZvxTTvJJNqcyx6rF3T-7otTTIHBOZIMhZauc5dob-sqcN_mT2htqm3BpSdlJlz60TBq6diOtlNhV212gQCEJMPZj0MNj7kZRj_GsECrTaU7FU0A3HAzkbdx15vQJMKZiFbbQCVI7-X2J0bZzQKIWfMHD-VgHFwOe6nomT-jbYIXtCBDd6fNj1zTKRl-_uzjVqNK-h8YW1h6tE4xvZmXyHQ1-9yNKZIWC7iEaPkBLaBKQulLU5MvW3AtVDUhzm6--5H1J85JH5QhRrnKYRon7ZW5q1AQ' \ -H 'ES-Client-Authentication: SharedSecret my_super_secret' \ -H "Accept: application/json" ---- { "username": "elastic-agent", "roles": [ "superuser" ], "full_name": null, "email": null, "metadata": { "jwt_claim_sub": "elastic-agent", "jwt_token_type": "access_token", "jwt_claim_iss": "https://kibana.elastic.co/jwt/", "jwt_claim_name": "Elastic Agent", "jwt_claim_aud": [ "elasticsearch" ] }, "enabled": true, "authentication_realm": { "name": "jwt_with_secret", "type": "jwt" }, "lookup_realm": { "name": "jwt_with_secret", "type": "jwt" }, "authentication_type": "realm", "authentication_provider": { "type": "http", "name": "__http__" }, "elastic_cloud_user": false } ``` __Fixes:__ https://github.com/elastic/kibana/issues/171522 ---- Release note: The default value of the `elasticsearch.requestHeadersWhitelist` configuration option has been expanded to include the `es-client-authentication` HTTP header, in addition to `authorization`. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/ftr_configs.yml | 1 + config/serverless.yml | 3 - docs/setup/settings.asciidoc | 2 +- .../src/elasticsearch_config.test.ts | 1 + .../src/elasticsearch_config.ts | 2 +- .../src/core_usage_data_service.ts | 2 +- .../plugins/monitoring/server/config.test.ts | 1 + .../authentication/api_keys/api_keys.test.ts | 45 +++++++ .../authentication/api_keys/api_keys.ts | 23 +++- .../http_authorization_header.test.ts | 17 +++ .../http_authorization_header.ts | 6 +- .../api_keys.config.ts | 26 ++++ .../http_bearer.config.ts | 31 +++++ .../tests/api_keys/grant_api_key.ts | 99 +++++++++++++++ .../tests/api_keys/index.ts | 14 +++ .../{header.ts => access_token.ts} | 2 +- .../tests/http_bearer/index.ts | 3 +- .../tests/http_bearer/jwt.ts | 118 ++++++++++++++++++ .../plugins/test_endpoints/server/index.ts | 3 + .../test_endpoints/server/init_routes.ts | 19 +++ .../plugins/test_endpoints/tsconfig.json | 1 + x-pack/test/tsconfig.json | 1 + 22 files changed, 408 insertions(+), 12 deletions(-) create mode 100644 x-pack/test/security_api_integration/api_keys.config.ts create mode 100644 x-pack/test/security_api_integration/tests/api_keys/grant_api_key.ts create mode 100644 x-pack/test/security_api_integration/tests/api_keys/index.ts rename x-pack/test/security_api_integration/tests/http_bearer/{header.ts => access_token.ts} (98%) create mode 100644 x-pack/test/security_api_integration/tests/http_bearer/jwt.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 5711d2f8322bba..27cce006478069 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -363,6 +363,7 @@ enabled: - x-pack/test/search_sessions_integration/config.ts - x-pack/test/security_api_integration/anonymous_es_anonymous.config.ts - x-pack/test/security_api_integration/anonymous.config.ts + - x-pack/test/security_api_integration/api_keys.config.ts - x-pack/test/security_api_integration/audit.config.ts - x-pack/test/security_api_integration/http_bearer.config.ts - x-pack/test/security_api_integration/http_no_auth_providers.config.ts diff --git a/config/serverless.yml b/config/serverless.yml index 30aa06ab8f05bf..575f411466c794 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -96,9 +96,6 @@ console.autocompleteDefinitions.endpointsAvailability: serverless # Do not check the ES version when running on Serverless elasticsearch.ignoreVersionMismatch: true -# Allow authentication via the Elasticsearch JWT realm with the `shared_secret` client authentication type. -elasticsearch.requestHeadersWhitelist: ['authorization', 'es-client-authentication'] - # Limit maxSockets to 800 as we do in ESS, which improves reliability under high loads. elasticsearch.maxSockets: 800 diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 37bb26ae53a38f..e43b70dd169410 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -113,7 +113,7 @@ List of {kib} client-side headers to send to {es}. To send *no* client-side headers, set this value to [] (an empty list). Removing the `authorization` header from being whitelisted means that you cannot use <> in {kib}. -*Default: `[ 'authorization' ]`* +*Default: `[ 'authorization', 'es-client-authentication' ]`* [[elasticsearch-requestTimeout]] `elasticsearch.requestTimeout`:: Time in milliseconds to wait for responses from the back end or {es}. diff --git a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.test.ts b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.test.ts index 039f1ff8ccb86b..77d1ec7542a7e6 100644 --- a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.test.ts +++ b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.test.ts @@ -45,6 +45,7 @@ test('set correct defaults', () => { "pingTimeout": "PT30S", "requestHeadersWhitelist": Array [ "authorization", + "es-client-authentication", ], "requestTimeout": "PT30S", "serviceAccountToken": undefined, diff --git a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.ts b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.ts index d7f426ec28e536..e3d0dba0a2fd1e 100644 --- a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.ts +++ b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_config.ts @@ -94,7 +94,7 @@ export const configSchema = schema.object({ }), ], { - defaultValue: ['authorization'], + defaultValue: ['authorization', 'es-client-authentication'], } ), customHeaders: schema.recordOf(schema.string(), schema.string(), { diff --git a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_data_service.ts b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_data_service.ts index 63a8da844e090e..04673a626860a8 100644 --- a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_data_service.ts +++ b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_data_service.ts @@ -251,7 +251,7 @@ export class CoreUsageDataService pingTimeoutMs: es.pingTimeout.asMilliseconds(), requestHeadersWhitelistConfigured: isConfigured.stringOrArray( es.requestHeadersWhitelist, - ['authorization'] + ['authorization', 'es-client-authentication'] ), requestTimeoutMs: es.requestTimeout.asMilliseconds(), shardTimeoutMs: es.shardTimeout.asMilliseconds(), diff --git a/x-pack/plugins/monitoring/server/config.test.ts b/x-pack/plugins/monitoring/server/config.test.ts index 0d6a6b169fc882..02d63ac16ba171 100644 --- a/x-pack/plugins/monitoring/server/config.test.ts +++ b/x-pack/plugins/monitoring/server/config.test.ts @@ -71,6 +71,7 @@ describe('config schema', () => { "pingTimeout": "PT30S", "requestHeadersWhitelist": Array [ "authorization", + "es-client-authentication", ], "requestTimeout": "PT30S", "shardTimeout": "PT30S", diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts index 8f0e58acf75adb..0d8f5a78c64b88 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts @@ -536,6 +536,51 @@ describe('API Keys', () => { }); }); + it('calls `grantApiKey` with proper parameters for the Bearer scheme with client authentication', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.asInternalUser.security.grantApiKey.mockResponseOnce({ + id: '123', + name: 'key-name', + api_key: 'abc123', + encoded: 'utf8', + }); + const result = await apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Bearer foo-access-token`, + 'es-client-authentication': 'SharedSecret secret', + }, + }), + { + name: 'test_api_key', + role_descriptors: { foo: true }, + expiration: '1d', + } + ); + expect(result).toEqual({ + api_key: 'abc123', + id: '123', + name: 'key-name', + encoded: 'utf8', + }); + expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled(); // this is only called if kibana_role_descriptors is defined + expect(mockClusterClient.asInternalUser.security.grantApiKey).toHaveBeenCalledWith({ + body: { + api_key: { + name: 'test_api_key', + role_descriptors: { foo: true }, + expiration: '1d', + }, + grant_type: 'access_token', + access_token: 'foo-access-token', + client_authentication: { + scheme: 'SharedSecret', + value: 'secret', + }, + }, + }); + }); + it('throw error for other schemes', async () => { mockLicense.isEnabled.mockReturnValue(true); await expect( diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts index 75f6d894e65ebd..36a3bfeee4f7c9 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts @@ -32,6 +32,8 @@ import { export type { UpdateAPIKeyParams, UpdateAPIKeyResult }; +const ELASTICSEARCH_CLIENT_AUTHENTICATION_HEADER = 'es-client-authentication'; + /** * Represents the options to create an APIKey class instance that will be * shared between functions (create, invalidate, etc). @@ -269,6 +271,13 @@ export class APIKeys implements APIKeysType { `Unable to grant an API Key, request does not contain an authorization header` ); } + + // Try to extract optional Elasticsearch client credentials (currently only used by JWT). + const clientAuthorizationHeader = HTTPAuthorizationHeader.parseFromRequest( + request, + ELASTICSEARCH_CLIENT_AUTHENTICATION_HEADER + ); + const { expiration, metadata, name } = createParams; const roleDescriptors = @@ -281,7 +290,8 @@ export class APIKeys implements APIKeysType { const params = this.getGrantParams( { expiration, metadata, name, role_descriptors: roleDescriptors }, - authorizationHeader + authorizationHeader, + clientAuthorizationHeader ); // User needs `manage_api_key` or `grant_api_key` privilege to use this API @@ -399,13 +409,22 @@ export class APIKeys implements APIKeysType { private getGrantParams( createParams: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams, - authorizationHeader: HTTPAuthorizationHeader + authorizationHeader: HTTPAuthorizationHeader, + clientAuthorizationHeader: HTTPAuthorizationHeader | null ): GrantAPIKeyParams { if (authorizationHeader.scheme.toLowerCase() === 'bearer') { return { api_key: createParams, grant_type: 'access_token', access_token: authorizationHeader.credentials, + ...(clientAuthorizationHeader + ? { + client_authentication: { + scheme: clientAuthorizationHeader.scheme, + value: clientAuthorizationHeader.credentials, + }, + } + : {}), }; } diff --git a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts index 5e79623b2febfe..b0f9467b86b981 100644 --- a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts @@ -75,6 +75,23 @@ describe('HTTPAuthorizationHeader.parseFromRequest()', () => { expect(header!.credentials).toBe(credentials); } }); + + it('parses custom headers', () => { + const mockRequest = httpServerMock.createKibanaRequest({ + headers: { 'es-client-authentication': 'SharedSecret secret' }, + }); + + // Doesn't parse custom headers by default. + expect(HTTPAuthorizationHeader.parseFromRequest(mockRequest)).toBeNull(); + + const header = HTTPAuthorizationHeader.parseFromRequest( + mockRequest, + 'es-client-authentication' + ); + expect(header).not.toBeNull(); + expect(header?.scheme).toBe('SharedSecret'); + expect(header?.credentials).toBe('secret'); + }); }); describe('toString()', () => { diff --git a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts index 78008de3ef3596..1ad5fde73427b5 100644 --- a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts @@ -27,9 +27,11 @@ export class HTTPAuthorizationHeader { /** * Parses request's `Authorization` HTTP header if present. * @param request Request instance to extract the authorization header from. + * @param [headerName] Optional name of the HTTP header to extract authentication information from. By default, the + * authentication information is extracted from the `Authorization` HTTP header. */ - static parseFromRequest(request: KibanaRequest) { - const authorizationHeaderValue = request.headers.authorization; + static parseFromRequest(request: KibanaRequest, headerName = 'authorization') { + const authorizationHeaderValue = request.headers[headerName.toLowerCase()]; if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { return null; } diff --git a/x-pack/test/security_api_integration/api_keys.config.ts b/x-pack/test/security_api_integration/api_keys.config.ts new file mode 100644 index 00000000000000..a1ec0e428845ab --- /dev/null +++ b/x-pack/test/security_api_integration/api_keys.config.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const httpBearerAPITestsConfig = await readConfigFile(require.resolve('./http_bearer.config.ts')); + + return { + testFiles: [require.resolve('./tests/api_keys')], + servers: httpBearerAPITestsConfig.get('servers'), + security: httpBearerAPITestsConfig.get('security'), + services, + junit: { + reportName: 'X-Pack Security API Integration Tests (Api Keys)', + }, + + esTestCluster: httpBearerAPITestsConfig.get('esTestCluster'), + kbnTestServer: httpBearerAPITestsConfig.get('kbnTestServer'), + }; +} diff --git a/x-pack/test/security_api_integration/http_bearer.config.ts b/x-pack/test/security_api_integration/http_bearer.config.ts index b0a9f4a9203470..87f0b39cbb648b 100644 --- a/x-pack/test/security_api_integration/http_bearer.config.ts +++ b/x-pack/test/security_api_integration/http_bearer.config.ts @@ -6,11 +6,15 @@ */ 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/plugins/test_endpoints'); + const jwksPath = require.resolve('@kbn/security-api-integration-helpers/oidc/jwks.json'); + return { testFiles: [require.resolve('./tests/http_bearer')], servers: xPackAPITestsConfig.get('servers'), @@ -26,11 +30,38 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), 'xpack.security.authc.token.enabled=true', 'xpack.security.authc.token.timeout=15s', + + // JWT WITH shared secret + 'xpack.security.authc.realms.jwt.jwt_with_secret.allowed_audiences=elasticsearch', + `xpack.security.authc.realms.jwt.jwt_with_secret.allowed_issuer=https://kibana.elastic.co/jwt/`, + `xpack.security.authc.realms.jwt.jwt_with_secret.allowed_signature_algorithms=[RS256]`, + `xpack.security.authc.realms.jwt.jwt_with_secret.allowed_subjects=elastic-agent`, + `xpack.security.authc.realms.jwt.jwt_with_secret.claims.principal=sub`, + 'xpack.security.authc.realms.jwt.jwt_with_secret.client_authentication.type=shared_secret', + `xpack.security.authc.realms.jwt.jwt_with_secret.client_authentication.shared_secret=my_super_secret`, + 'xpack.security.authc.realms.jwt.jwt_with_secret.order=0', + `xpack.security.authc.realms.jwt.jwt_with_secret.pkc_jwkset_path=${jwksPath}`, + `xpack.security.authc.realms.jwt.jwt_with_secret.token_type=access_token`, + + // JWT WITHOUT shared secret + 'xpack.security.authc.realms.jwt.jwt_without_secret.allowed_audiences=elasticsearch', + `xpack.security.authc.realms.jwt.jwt_without_secret.allowed_issuer=https://kibana.elastic.co/jwt/no-secret`, + `xpack.security.authc.realms.jwt.jwt_without_secret.allowed_signature_algorithms=[RS256]`, + `xpack.security.authc.realms.jwt.jwt_without_secret.allowed_subjects=elastic-agent-no-secret`, + `xpack.security.authc.realms.jwt.jwt_without_secret.claims.principal=sub`, + 'xpack.security.authc.realms.jwt.jwt_without_secret.client_authentication.type=none', + 'xpack.security.authc.realms.jwt.jwt_without_secret.order=1', + `xpack.security.authc.realms.jwt.jwt_without_secret.pkc_jwkset_path=${jwksPath}`, + `xpack.security.authc.realms.jwt.jwt_without_secret.token_type=access_token`, ], }, kbnTestServer: { ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${testEndpointsPlugin}`, + ], }, }; } diff --git a/x-pack/test/security_api_integration/tests/api_keys/grant_api_key.ts b/x-pack/test/security_api_integration/tests/api_keys/grant_api_key.ts new file mode 100644 index 00000000000000..bc559f47ed21de --- /dev/null +++ b/x-pack/test/security_api_integration/tests/api_keys/grant_api_key.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { adminTestUser } from '@kbn/test'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esSupertest = getService('esSupertest'); + + describe('Grant API keys', () => { + async function validateApiKey(username: string, encodedApiKey: string) { + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Authorization', `ApiKey ${encodedApiKey}`) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_provider).to.eql({ name: '__http__', type: 'http' }); + expect(user.authentication_type).to.eql('api_key'); + } + + it('should properly grant API key with `Basic` credentials', async function () { + const credentials = Buffer.from( + `${adminTestUser.username}:${adminTestUser.password}` + ).toString('base64'); + + const { body: apiKey } = await supertest + .post('/api_keys/_grant') + .set('Authorization', `Basic ${credentials}`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'my-basic-api-key' }) + .expect(200); + expect(apiKey.name).to.eql('my-basic-api-key'); + + await validateApiKey(adminTestUser.username, apiKey.encoded); + }); + + it('should properly grant API key with `Bearer` credentials', async function () { + const { body: token } = await esSupertest + .post('/_security/oauth2/token') + .send({ grant_type: 'password', ...adminTestUser }) + .expect(200); + + const { body: apiKey } = await supertest + .post('/api_keys/_grant') + .set('Authorization', `Bearer ${token.access_token}`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'my-bearer-api-key' }) + .expect(200); + expect(apiKey.name).to.eql('my-bearer-api-key'); + + await validateApiKey(adminTestUser.username, apiKey.encoded); + }); + + describe('with JWT credentials', function () { + // When we run tests on MKI, JWT realm is configured differently, and we cannot handcraft valid JWTs. We create + // separate `describe` since `this.tags` only works on a test suite level. + this.tags(['skipMKI']); + + it('should properly grant API key (with client authentication)', async function () { + const jsonWebToken = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC8iLCJzdWIiOiJlbGFzdGljLWFnZW50IiwiYXVkIjoiZWxhc3RpY3NlYXJjaCIsIm5hbWUiOiJFbGFzdGljIEFnZW50IiwiaWF0Ijo5NDY2ODQ4MDAsImV4cCI6NDA3MDkwODgwMH0.P7RHKZlLskS5DfVRqoVO4ivoIq9rXl2-GW6hhC9NvTSkwphYivcjpTVcyENZvxTTvJJNqcyx6rF3T-7otTTIHBOZIMhZauc5dob-sqcN_mT2htqm3BpSdlJlz60TBq6diOtlNhV212gQCEJMPZj0MNj7kZRj_GsECrTaU7FU0A3HAzkbdx15vQJMKZiFbbQCVI7-X2J0bZzQKIWfMHD-VgHFwOe6nomT-jbYIXtCBDd6fNj1zTKRl-_uzjVqNK-h8YW1h6tE4xvZmXyHQ1-9yNKZIWC7iEaPkBLaBKQulLU5MvW3AtVDUhzm6--5H1J85JH5QhRrnKYRon7ZW5q1AQ'; + + const { body: apiKey } = await supertest + .post('/api_keys/_grant') + .set('Authorization', `Bearer ${jsonWebToken}`) + .set('ES-Client-Authentication', 'SharedSecret my_super_secret') + .set('kbn-xsrf', 'xxx') + .send({ name: 'my-jwt-secret-api-key' }) + .expect(200); + expect(apiKey.name).to.eql('my-jwt-secret-api-key'); + + await validateApiKey('elastic-agent', apiKey.encoded); + }); + + it('should properly grant API key (without client authentication)', async function () { + const jsonWebToken = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC9uby1zZWNyZXQiLCJzdWIiOiJlbGFzdGljLWFnZW50LW5vLXNlY3JldCIsImF1ZCI6ImVsYXN0aWNzZWFyY2giLCJuYW1lIjoiRWxhc3RpYyBBZ2VudCIsImlhdCI6OTQ2Njg0ODAwLCJleHAiOjQwNzA5MDg4MDB9.OZ_XIDqMmoWr8XqbWE9C04l1NYMsbGXG0zGPdztT-7PuZirzbSvm8z9T7SqbvsujUMn78vpeHx1HyBukrzrBXw2PKeVCa6PGPBtJ_m1fpsCffelHGAD3n2Mu3HanQmdmamHG6JbyLGUwWJ9F31M1xWFAtnMTqP0yeaDOw_9t0WVXHAedVNjvJIrz2X09GHpa9RXxSA0hDuzPotw41kzSrCOhsiBXTNUUNiv4BQ6LNmxbIS6XcXab6LxnQEKtu7XbziaokHKjdZpVAWG8GF8fu0i77GGszNE30RBonYUUPbBrBjhEueK7M8HXTwdHCalRMGsXqD8qS0-TGzii6G-4vg'; + + const { body: apiKey } = await supertest + .post('/api_keys/_grant') + .set('Authorization', `Bearer ${jsonWebToken}`) + .set('kbn-xsrf', 'xxx') + .send({ name: 'my-jwt-api-key' }) + .expect(200); + expect(apiKey.name).to.eql('my-jwt-api-key'); + + await validateApiKey('elastic-agent-no-secret', apiKey.encoded); + }); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/api_keys/index.ts b/x-pack/test/security_api_integration/tests/api_keys/index.ts new file mode 100644 index 00000000000000..a20f0a30181ff2 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/api_keys/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - Api Keys', function () { + loadTestFile(require.resolve('./grant_api_key')); + }); +} diff --git a/x-pack/test/security_api_integration/tests/http_bearer/header.ts b/x-pack/test/security_api_integration/tests/http_bearer/access_token.ts similarity index 98% rename from x-pack/test/security_api_integration/tests/http_bearer/header.ts rename to x-pack/test/security_api_integration/tests/http_bearer/access_token.ts index 1cc080c3b3e770..3f6d89fb5ff9d0 100644 --- a/x-pack/test/security_api_integration/tests/http_bearer/header.ts +++ b/x-pack/test/security_api_integration/tests/http_bearer/access_token.ts @@ -32,7 +32,7 @@ export default function ({ getService }: FtrProviderContext) { }; } - describe('header', () => { + describe('access token', () => { it('accepts valid access token via authorization Bearer header', async () => { const { accessToken, expectedUser } = await createToken(); diff --git a/x-pack/test/security_api_integration/tests/http_bearer/index.ts b/x-pack/test/security_api_integration/tests/http_bearer/index.ts index c796c0be9befb3..66619192bca48b 100644 --- a/x-pack/test/security_api_integration/tests/http_bearer/index.ts +++ b/x-pack/test/security_api_integration/tests/http_bearer/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('security APIs - HTTP Bearer', function () { - loadTestFile(require.resolve('./header')); + loadTestFile(require.resolve('./access_token')); + loadTestFile(require.resolve('./jwt')); }); } diff --git a/x-pack/test/security_api_integration/tests/http_bearer/jwt.ts b/x-pack/test/security_api_integration/tests/http_bearer/jwt.ts new file mode 100644 index 00000000000000..4806bc410cd846 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/http_bearer/jwt.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { AuthenticatedUser } from '@kbn/security-plugin-types-common'; +import { adminTestUser } from '@kbn/test'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const es = getService('es'); + + const jsonWebTokenRequiresSecret = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC8iLCJzdWIiOiJlbGFzdGljLWFnZW50IiwiYXVkIjoiZWxhc3RpY3NlYXJjaCIsIm5hbWUiOiJFbGFzdGljIEFnZW50IiwiaWF0Ijo5NDY2ODQ4MDAsImV4cCI6NDA3MDkwODgwMH0.P7RHKZlLskS5DfVRqoVO4ivoIq9rXl2-GW6hhC9NvTSkwphYivcjpTVcyENZvxTTvJJNqcyx6rF3T-7otTTIHBOZIMhZauc5dob-sqcN_mT2htqm3BpSdlJlz60TBq6diOtlNhV212gQCEJMPZj0MNj7kZRj_GsECrTaU7FU0A3HAzkbdx15vQJMKZiFbbQCVI7-X2J0bZzQKIWfMHD-VgHFwOe6nomT-jbYIXtCBDd6fNj1zTKRl-_uzjVqNK-h8YW1h6tE4xvZmXyHQ1-9yNKZIWC7iEaPkBLaBKQulLU5MvW3AtVDUhzm6--5H1J85JH5QhRrnKYRon7ZW5q1AQ'; + const jsonWebToken = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC9uby1zZWNyZXQiLCJzdWIiOiJlbGFzdGljLWFnZW50LW5vLXNlY3JldCIsImF1ZCI6ImVsYXN0aWNzZWFyY2giLCJuYW1lIjoiRWxhc3RpYyBBZ2VudCIsImlhdCI6OTQ2Njg0ODAwLCJleHAiOjQwNzA5MDg4MDB9.OZ_XIDqMmoWr8XqbWE9C04l1NYMsbGXG0zGPdztT-7PuZirzbSvm8z9T7SqbvsujUMn78vpeHx1HyBukrzrBXw2PKeVCa6PGPBtJ_m1fpsCffelHGAD3n2Mu3HanQmdmamHG6JbyLGUwWJ9F31M1xWFAtnMTqP0yeaDOw_9t0WVXHAedVNjvJIrz2X09GHpa9RXxSA0hDuzPotw41kzSrCOhsiBXTNUUNiv4BQ6LNmxbIS6XcXab6LxnQEKtu7XbziaokHKjdZpVAWG8GF8fu0i77GGszNE30RBonYUUPbBrBjhEueK7M8HXTwdHCalRMGsXqD8qS0-TGzii6G-4vg'; + const jsonWebTokenInvalidIssuer = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC9pbnZhbGlkIiwic3ViIjoiZWxhc3RpYy1hZ2VudCIsImF1ZCI6ImVsYXN0aWNzZWFyY2giLCJuYW1lIjoiRWxhc3RpYyBBZ2VudCIsImlhdCI6OTQ2Njg0ODAwLCJleHAiOjQwNzA5MDg4MDB9.XIbQM1tGanl039fNBR41FJ-GB4ZEHWWu8AYAqar_26Jje2BOt3yAVEERvbo1KcE6C20UdU1EgtXeQcwI6Bx6yi3KVkq7sRRygkq2j33lP_ZD3CfzZPiMwpIL5vOvf9J_DjUIZOhz1aqx4HYlBYkbK7xjE3gYeEvvVFAYFu2PNbtHmI-p3BAOpeCwbuogDzaSxUaUpAylBS2AJx8HCgSliKsDEwB3ICfyEawsyM7UYoDJutCjcYyP8jtGDVr_RgOaHBrwty0BsqJldmsHfkx86oUJZiO2cpMjo-lDmQgNhyFktFmnDTcBhN3jWbCJgi2FUmUU0dm4e3arzqU2xYyZiA'; + + function assertUser(user: AuthenticatedUser, username: string, realmType = 'jwt') { + expect(user.username).to.eql(username); + expect(user.authentication_provider).to.eql({ name: '__http__', type: 'http' }); + expect(user.authentication_realm.type).to.eql(realmType); + } + + describe('jwt', function () { + // When we run tests on MKI, JWT realm is configured differently, and we cannot handcraft valid JWTs. We create + // separate `describe` since `this.tags` only works on a test suite level. + this.tags(['skipMKI']); + + it('accepts valid JWT (with secret) via authorization Bearer header', async () => { + const { body: user } = await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebTokenRequiresSecret}`) + .set('ES-Client-Authentication', 'SharedSecret my_super_secret') + .expect(200); + + assertUser(user, 'elastic-agent'); + }); + + it('accepts valid JWT (without secret) via authorization Bearer header', async () => { + const { body: user } = await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebToken}`) + .expect(200); + + assertUser(user, 'elastic-agent-no-secret'); + }); + + it('accepts valid JWT and non-JWT tokens that do not require secret but the secret was supplied', async () => { + const { body: user } = await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebToken}`) + .set('ES-Client-Authentication', 'SharedSecret my_super_secret') + .expect(200); + assertUser(user, 'elastic-agent-no-secret'); + + const { access_token: accessToken } = await es.security.getToken({ + body: { grant_type: 'password', ...adminTestUser }, + }); + const { body: accessTokenUser } = await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${accessToken}`) + .set('ES-Client-Authentication', 'SharedSecret my_super_secret') + .expect(200); + assertUser(accessTokenUser, adminTestUser.username, 'reserved'); + }); + + it('rejects invalid JWT', async () => { + await supertest + .get('/internal/security/me') + .set('authorization', `Bearer _${jsonWebTokenRequiresSecret}`) + .set('ES-Client-Authentication', 'SharedSecret my_super_secret') + .expect(401); + + await supertest + .get('/internal/security/me') + .set('authorization', `Bearer _${jsonWebToken}`) + .expect(401); + + await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebTokenInvalidIssuer}`) + .expect(401); + }); + + it('rejects invalid JWT secret', async () => { + await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebTokenRequiresSecret}`) + .set('ES-Client-Authentication', 'SharedSecret my_wrong_secret') + .expect(401); + + await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebTokenRequiresSecret}`) + .set('ES-Client-Authentication', 'PrivateSecret my_super_secret') + .expect(401); + + await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebTokenRequiresSecret}`) + .set('ES-Client-Authentication', 'SharedSecret_my_super_secret') + .expect(401); + }); + + it('rejects JWT with required, but missing secret', async () => { + await supertest + .get('/internal/security/me') + .set('authorization', `Bearer ${jsonWebTokenRequiresSecret}`) + .expect(401); + }); + }); +} diff --git a/x-pack/test/security_functional/plugins/test_endpoints/server/index.ts b/x-pack/test/security_functional/plugins/test_endpoints/server/index.ts index 38ad79b2132184..93097b9fa712e4 100644 --- a/x-pack/test/security_functional/plugins/test_endpoints/server/index.ts +++ b/x-pack/test/security_functional/plugins/test_endpoints/server/index.ts @@ -10,13 +10,16 @@ import { TaskManagerSetupContract, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; +import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import { initRoutes } from './init_routes'; export interface PluginSetupDependencies { + security: SecurityPluginSetup; taskManager: TaskManagerSetupContract; } export interface PluginStartDependencies { + security: SecurityPluginStart; taskManager: TaskManagerStartContract; } diff --git a/x-pack/test/security_functional/plugins/test_endpoints/server/init_routes.ts b/x-pack/test/security_functional/plugins/test_endpoints/server/init_routes.ts index e77ebd8ea5af1f..54c2f2500ff634 100644 --- a/x-pack/test/security_functional/plugins/test_endpoints/server/init_routes.ts +++ b/x-pack/test/security_functional/plugins/test_endpoints/server/init_routes.ts @@ -13,6 +13,7 @@ import type { ConcreteTaskInstance, BulkUpdateTaskResult, } from '@kbn/task-manager-plugin/server'; +import { restApiKeySchema } from '@kbn/security-plugin-types-server'; import { PluginStartDependencies } from '.'; export const SESSION_INDEX_CLEANUP_TASK_NAME = 'session_cleanup'; @@ -108,6 +109,24 @@ export function initRoutes( } ); + router.post( + { + path: '/api_keys/_grant', + validate: { body: restApiKeySchema }, + }, + async (context, request, response) => { + const [, { security }] = await core.getStartServices(); + const apiKey = await security.authc.apiKeys.grantAsInternalUser(request, request.body); + if (!apiKey) { + throw new Error( + `Couldn't generate API key with the following parameters: ${JSON.stringify(request.body)}` + ); + } + + return response.ok({ body: apiKey }); + } + ); + async function waitUntilTaskIsIdle(taskManager: TaskManagerStartContract) { logger.info(`Waiting until session cleanup task is in idle.`); diff --git a/x-pack/test/security_functional/plugins/test_endpoints/tsconfig.json b/x-pack/test/security_functional/plugins/test_endpoints/tsconfig.json index 43a87600f622d8..af8d31cde61d77 100644 --- a/x-pack/test/security_functional/plugins/test_endpoints/tsconfig.json +++ b/x-pack/test/security_functional/plugins/test_endpoints/tsconfig.json @@ -15,5 +15,6 @@ "@kbn/security-plugin", "@kbn/task-manager-plugin", "@kbn/config-schema", + "@kbn/security-plugin-types-server", ] } diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 0c763cbf20c5e2..cd9bde5d36a733 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -149,5 +149,6 @@ "@kbn/reporting-export-types-pdf-common", "@kbn/reporting-export-types-png-common", "@kbn/reporting-common", + "@kbn/security-plugin-types-common", ] }