diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index bcb212e7bbf94..78b1d5f8e30b8 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -15,6 +15,8 @@ import { } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; +const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64'); + describe('API Keys', () => { let apiKeys: APIKeys; let mockClusterClient: jest.Mocked; @@ -81,6 +83,87 @@ describe('API Keys', () => { }); }); + describe('grantAsInternalUser()', () => { + it('returns null when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + const result = await apiKeys.grantAsInternalUser(httpServerMock.createKibanaRequest()); + expect(result).toBeNull(); + + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('calls callAsInternalUser with proper parameters for the Basic scheme', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + id: '123', + name: 'key-name', + api_key: 'abc123', + }); + const result = await apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Basic ${encodeToBase64('foo:bar')}`, + }, + }) + ); + expect(result).toEqual({ + api_key: 'abc123', + id: '123', + name: 'key-name', + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + body: { + grant_type: 'password', + username: 'foo', + password: 'bar', + }, + }); + }); + + it('calls callAsInternalUser with proper parameters for the Bearer scheme', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + id: '123', + name: 'key-name', + api_key: 'abc123', + }); + const result = await apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Bearer foo-access-token`, + }, + }) + ); + expect(result).toEqual({ + api_key: 'abc123', + id: '123', + name: 'key-name', + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + body: { + grant_type: 'access_token', + access_token: 'foo-access-token', + }, + }); + }); + + it('throw error for other schemes', async () => { + mockLicense.isEnabled.mockReturnValue(true); + await expect( + apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Digest username="foo"`, + }, + }) + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unsupported scheme \\"Digest\\" for granting API Key"` + ); + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + }); + describe('invalidate()', () => { it('returns null when security feature is disabled', async () => { mockLicense.isEnabled.mockReturnValue(false); diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 2b1a93d907471..0d77207e390ae 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -6,6 +6,8 @@ import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; +import { HTTPAuthorizationHeader } from './http_authentication'; +import { BasicHTTPAuthorizationHeaderCredentials } from './http_authentication'; /** * Represents the options to create an APIKey class instance that will be @@ -26,6 +28,13 @@ export interface CreateAPIKeyParams { expiration?: string; } +interface GrantAPIKeyParams { + grant_type: 'password' | 'access_token'; + username?: string; + password?: string; + access_token?: string; +} + /** * Represents the params for invalidating an API key */ @@ -58,6 +67,21 @@ export interface CreateAPIKeyResult { api_key: string; } +export interface GrantAPIKeyResult { + /** + * Unique id for this API key + */ + id: string; + /** + * Name for this API key + */ + name: string; + /** + * Generated API key + */ + api_key: string; +} + /** * The return value when invalidating an API key in Elasticsearch. */ @@ -131,6 +155,39 @@ export class APIKeys { return result; } + /** + * Tries to grant an API key for the current user. + * @param request Request instance. + */ + async grantAsInternalUser(request: KibanaRequest) { + if (!this.license.isEnabled()) { + return null; + } + + this.logger.debug('Trying to grant an API key'); + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader == null) { + throw new Error( + `Unable to grant an API Key, request does not contain an authorization header` + ); + } + const params = this.getGrantParams(authorizationHeader); + + // User needs `manage_api_key` or `grant_api_key` privilege to use this API + let result: GrantAPIKeyResult; + try { + result = (await this.clusterClient.callAsInternalUser('shield.grantAPIKey', { + body: params, + })) as GrantAPIKeyResult; + this.logger.debug('API key was granted successfully'); + } catch (e) { + this.logger.error(`Failed to grant API key: ${e.message}`); + throw e; + } + + return result; + } + /** * Tries to invalidate an API key. * @param request Request instance. @@ -164,4 +221,26 @@ export class APIKeys { return result; } + + private getGrantParams(authorizationHeader: HTTPAuthorizationHeader): GrantAPIKeyParams { + if (authorizationHeader.scheme.toLowerCase() === 'bearer') { + return { + grant_type: 'access_token', + access_token: authorizationHeader.credentials, + }; + } + + if (authorizationHeader.scheme.toLowerCase() === 'basic') { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + authorizationHeader.credentials + ); + return { + grant_type: 'password', + username: basicCredentials.username, + password: basicCredentials.password, + }; + } + + throw new Error(`Unsupported scheme "${authorizationHeader.scheme}" for granting API Key`); + } } diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts deleted file mode 100644 index 6a63634394ec0..0000000000000 --- a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { httpServerMock } from '../../../../../src/core/server/http/http_server.mocks'; - -import { getHTTPAuthenticationScheme } from './get_http_authentication_scheme'; - -describe('getHTTPAuthenticationScheme', () => { - it('returns `null` if request does not have authorization header', () => { - expect(getHTTPAuthenticationScheme(httpServerMock.createKibanaRequest())).toBeNull(); - }); - - it('returns `null` if authorization header value isn not a string', () => { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ - headers: { authorization: ['Basic xxx', 'Bearer xxx'] as any }, - }) - ) - ).toBeNull(); - }); - - it('returns `null` if authorization header value is an empty string', () => { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) - ) - ).toBeNull(); - }); - - it('returns only scheme portion of the authorization header value in lower case', () => { - const headerValueAndSchemeMap = [ - ['Basic xxx', 'basic'], - ['Basic xxx yyy', 'basic'], - ['basic xxx', 'basic'], - ['basic', 'basic'], - // We don't trim leading whitespaces in scheme. - [' Basic xxx', ''], - ['Negotiate xxx', 'negotiate'], - ['negotiate xxx', 'negotiate'], - ['negotiate', 'negotiate'], - ['ApiKey xxx', 'apikey'], - ['apikey xxx', 'apikey'], - ['Api Key xxx', 'api'], - ]; - - for (const [authorization, scheme] of headerValueAndSchemeMap) { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ headers: { authorization } }) - ) - ).toBe(scheme); - } - }); -}); diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts deleted file mode 100644 index b9c53f34dbcab..0000000000000 --- a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { KibanaRequest } from '../../../../../src/core/server'; - -/** - * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. - * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes - * @param request Request instance to extract authentication scheme for. - */ -export function getHTTPAuthenticationScheme(request: KibanaRequest) { - const authorizationHeaderValue = request.headers.authorization; - if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { - return null; - } - - return authorizationHeaderValue.split(/\s+/)[0].toLowerCase(); -} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts new file mode 100644 index 0000000000000..bd3c7047e77e7 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { BasicHTTPAuthorizationHeaderCredentials } from './basic_http_authorization_header_credentials'; + +const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64'); + +describe('BasicHTTPAuthorizationHeaderCredentials.parseFromRequest()', () => { + it('parses username from the left-side of the single colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr') + ); + expect(basicCredentials.username).toBe('fOo'); + }); + + it('parses username from the left-side of the first colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr:bAz') + ); + expect(basicCredentials.username).toBe('fOo'); + }); + + it('parses password from the right-side of the single colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr') + ); + expect(basicCredentials.password).toBe('bAr'); + }); + + it('parses password from the right-side of the first colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr:bAz') + ); + expect(basicCredentials.password).toBe('bAr:bAz'); + }); + + it('throws error if there is no colon', () => { + expect(() => { + BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials(encodeToBase64('fOobArbAz')); + }).toThrowErrorMatchingInlineSnapshot( + `"Unable to parse basic authentication credentials without a colon"` + ); + }); +}); + +describe(`toString()`, () => { + it('concatenates username and password using a colon and then base64 encodes the string', () => { + const basicCredentials = new BasicHTTPAuthorizationHeaderCredentials('elastic', 'changeme'); + + expect(basicCredentials.toString()).toEqual(Buffer.from(`elastic:changeme`).toString('base64')); // I don't like that this so closely mirror the actual implementation + expect(basicCredentials.toString()).toEqual('ZWxhc3RpYzpjaGFuZ2VtZQ=='); // and I don't like that this is so opaque. Both together seem reasonable... + }); +}); diff --git a/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts new file mode 100644 index 0000000000000..b8c3f1dadf1b2 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class BasicHTTPAuthorizationHeaderCredentials { + /** + * Username, referred to as the `user-id` in https://tools.ietf.org/html/rfc7617. + */ + readonly username: string; + + /** + * Password used to authenticate + */ + readonly password: string; + + constructor(username: string, password: string) { + this.username = username; + this.password = password; + } + + /** + * Parses the username and password from the credentials included in a HTTP Authorization header + * for the Basic scheme https://tools.ietf.org/html/rfc7617 + * @param credentials The credentials extracted from the HTTP Authorization header + */ + static parseFromCredentials(credentials: string) { + const decoded = Buffer.from(credentials, 'base64').toString(); + if (decoded.indexOf(':') === -1) { + throw new Error('Unable to parse basic authentication credentials without a colon'); + } + + const [username] = decoded.split(':'); + // according to https://tools.ietf.org/html/rfc7617, everything + // after the first colon is considered to be part of the password + const password = decoded.substring(username.length + 1); + return new BasicHTTPAuthorizationHeaderCredentials(username, password); + } + + toString() { + return Buffer.from(`${this.username}:${this.password}`).toString('base64'); + } +} 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 new file mode 100644 index 0000000000000..d47a0c70f608a --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { httpServerMock } from '../../../../../../src/core/server/mocks'; + +import { HTTPAuthorizationHeader } from './http_authorization_header'; + +describe('HTTPAuthorizationHeader.parseFromRequest()', () => { + it('returns `null` if request does not have authorization header', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest(httpServerMock.createKibanaRequest()) + ).toBeNull(); + }); + + it('returns `null` if authorization header value is not a string', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ + headers: { authorization: ['Basic xxx', 'Bearer xxx'] as any }, + }) + ) + ).toBeNull(); + }); + + it('returns `null` if authorization header value is an empty string', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) + ) + ).toBeNull(); + }); + + it('parses scheme portion of the authorization header value', () => { + const headerValueAndSchemeMap = [ + ['Basic xxx', 'Basic'], + ['Basic xxx yyy', 'Basic'], + ['basic xxx', 'basic'], + ['basic', 'basic'], + // We don't trim leading whitespaces in scheme. + [' Basic xxx', ''], + ['Negotiate xxx', 'Negotiate'], + ['negotiate xxx', 'negotiate'], + ['negotiate', 'negotiate'], + ['ApiKey xxx', 'ApiKey'], + ['apikey xxx', 'apikey'], + ['Api Key xxx', 'Api'], + ]; + + for (const [authorization, scheme] of headerValueAndSchemeMap) { + const header = HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization } }) + ); + expect(header).not.toBeNull(); + expect(header!.scheme).toBe(scheme); + } + }); + + it('parses credentials portion of the authorization header value', () => { + const headerValueAndCredentialsMap = [ + ['xxx fOo', 'fOo'], + ['xxx fOo bAr', 'fOo bAr'], + // We don't trim leading whitespaces in scheme. + [' xxx fOo', 'xxx fOo'], + ]; + + for (const [authorization, credentials] of headerValueAndCredentialsMap) { + const header = HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization } }) + ); + expect(header).not.toBeNull(); + expect(header!.credentials).toBe(credentials); + } + }); +}); + +describe('toString()', () => { + it('concatenates scheme and credentials using a space', () => { + const header = new HTTPAuthorizationHeader('Bearer', 'some-access-token'); + + expect(header.toString()).toEqual('Bearer some-access-token'); + }); +}); 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 new file mode 100644 index 0000000000000..bfc757734ec72 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts @@ -0,0 +1,45 @@ +/* + * 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 { KibanaRequest } from '../../../../../../src/core/server'; + +export class HTTPAuthorizationHeader { + /** + * The authentication scheme. Should be consumed in a case-insensitive manner. + * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes + */ + readonly scheme: string; + + /** + * The authentication credentials for the scheme. + */ + readonly credentials: string; + + constructor(scheme: string, credentials: string) { + this.scheme = scheme; + this.credentials = credentials; + } + + /** + * Parses request's `Authorization` HTTP header if present. + * @param request Request instance to extract the authorization header from. + */ + static parseFromRequest(request: KibanaRequest) { + const authorizationHeaderValue = request.headers.authorization; + if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { + return null; + } + + const [scheme] = authorizationHeaderValue.split(/\s+/); + const credentials = authorizationHeaderValue.substring(scheme.length + 1); + + return new HTTPAuthorizationHeader(scheme, credentials); + } + + toString() { + return `${this.scheme} ${this.credentials}`; + } +} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/index.ts b/x-pack/plugins/security/server/authentication/http_authentication/index.ts new file mode 100644 index 0000000000000..94eb8762ecaf0 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { BasicHTTPAuthorizationHeaderCredentials } from './basic_http_authorization_header_credentials'; +export { HTTPAuthorizationHeader } from './http_authorization_header'; diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index c634e2c80c299..512de9626a986 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -13,6 +13,7 @@ export const authenticationMock = { isProviderEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), + grantAPIKeyAsInternalUser: jest.fn(), invalidateAPIKey: jest.fn(), isAuthenticated: jest.fn(), getSessionInfo: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 973c322285db1..fa01c784b657d 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -415,6 +415,24 @@ describe('setupAuthentication()', () => { }); }); + describe('grantAPIKeyAsInternalUser()', () => { + let grantAPIKeyAsInternalUser: (request: KibanaRequest) => Promise; + beforeEach(async () => { + grantAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) + .grantAPIKeyAsInternalUser; + }); + + it('calls grantAsInternalUser', async () => { + const request = httpServerMock.createKibanaRequest(); + const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; + apiKeysInstance.grantAsInternalUser.mockResolvedValueOnce({ api_key: 'foo' }); + await expect(grantAPIKeyAsInternalUser(request)).resolves.toEqual({ + api_key: 'foo', + }); + expect(apiKeysInstance.grantAsInternalUser).toHaveBeenCalledWith(request); + }); + }); + describe('invalidateAPIKey()', () => { let invalidateAPIKey: ( request: KibanaRequest, diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 5109c174344d8..1e203088f32e5 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -28,6 +28,10 @@ export { CreateAPIKeyParams, InvalidateAPIKeyParams, } from './api_keys'; +export { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from './http_authentication'; interface SetupAuthenticationParams { http: CoreSetup['http']; @@ -181,6 +185,7 @@ export async function setupAuthentication({ getCurrentUser, createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), + grantAPIKeyAsInternalUser: (request: KibanaRequest) => apiKeys.grantAsInternalUser(request), invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) => apiKeys.invalidate(request, params), isAuthenticated: (request: KibanaRequest) => http.auth.isAuthenticated(request), diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index ad46aff8afa51..76a9f936eca48 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -8,7 +8,10 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { + HTTPAuthorizationHeader, + BasicHTTPAuthorizationHeaderCredentials, +} from '../http_authentication'; import { BaseAuthenticationProvider } from './base'; /** @@ -54,7 +57,10 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to perform a login.'); const authHeaders = { - authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials(username, password).toString() + ).toString(), }; try { @@ -76,7 +82,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts index 57163bf8145b8..6b75ae2d48156 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -7,7 +7,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; interface HTTPAuthenticationProviderOptions { @@ -38,7 +38,9 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { if ((httpOptions?.supportedSchemes?.size ?? 0) === 0) { throw new Error('Supported schemes should be specified'); } - this.supportedSchemes = httpOptions.supportedSchemes; + this.supportedSchemes = new Set( + [...httpOptions.supportedSchemes].map(scheme => scheme.toLowerCase()) + ); } /** @@ -56,26 +58,26 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getHTTPAuthenticationScheme(request); - if (authenticationScheme == null) { + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader == null) { this.logger.debug('Authorization header is not presented.'); return AuthenticationResult.notHandled(); } - if (!this.supportedSchemes.has(authenticationScheme)) { - this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + if (!this.supportedSchemes.has(authorizationHeader.scheme.toLowerCase())) { + this.logger.debug(`Unsupported authentication scheme: ${authorizationHeader.scheme}`); return AuthenticationResult.notHandled(); } try { const user = await this.getUser(request); this.logger.debug( - `Request to ${request.url.path} has been authenticated via authorization header with "${authenticationScheme}" scheme.` + `Request to ${request.url.path} has been authenticated via authorization header with "${authorizationHeader.scheme}" scheme.` ); return AuthenticationResult.succeeded(user); } catch (err) { this.logger.debug( - `Failed to authenticate request to ${request.url.path} via authorization header with "${authenticationScheme}" scheme: ${err.message}` + `Failed to authenticate request to ${request.url.path} via authorization header with "${authorizationHeader.scheme}" scheme: ${err.message}` ); return AuthenticationResult.failed(err); } diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 632a07ca2b21a..dbd0a438d71c9 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -12,7 +12,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -44,13 +44,13 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getHTTPAuthenticationScheme(request); - if (authenticationScheme && authenticationScheme !== 'negotiate') { - this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader && authorizationHeader.scheme.toLowerCase() !== 'negotiate') { + this.logger.debug(`Unsupported authentication scheme: ${authorizationHeader.scheme}`); return AuthenticationResult.notHandled(); } - let authenticationResult = authenticationScheme + let authenticationResult = authorizationHeader ? await this.authenticateWithNegotiateScheme(request) : AuthenticationResult.notHandled(); @@ -175,7 +175,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { try { // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${tokens.access_token}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', tokens.access_token).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('User has been authenticated with new access token'); @@ -205,7 +207,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -242,7 +246,12 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index d52466826c2be..21bce028b0d98 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -10,7 +10,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { AuthenticationProviderOptions, @@ -131,7 +131,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -289,7 +289,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -345,7 +347,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 252ab8cc67144..db022ff355702 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -9,7 +9,7 @@ import { DetailedPeerCertificate } from 'tls'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -45,7 +45,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -156,7 +156,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -207,7 +209,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { try { // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('User has been authenticated with new access token'); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index d56892785d0e0..ad3720d7e3335 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -11,7 +11,7 @@ import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; @@ -180,7 +180,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -416,7 +416,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -471,7 +473,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index fffac254ed30a..91808c22c4300 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -9,7 +9,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -60,7 +60,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Get token API request to Elasticsearch successful'); // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Login has been successfully performed.'); @@ -82,7 +84,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -152,7 +154,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate via state.'); try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -199,7 +203,12 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts index 996dcb685f29b..529e8a8aa6e9c 100644 --- a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts +++ b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts @@ -538,6 +538,24 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen }, }); + /** + * Grants an API key in Elasticsearch for the current user. + * + * @param {string} type The type of grant, either "password" or "access_token" + * @param {string} username Required when using the "password" type + * @param {string} password Required when using the "password" type + * @param {string} access_token Required when using the "access_token" type + * + * @returns {{api_key: string}} + */ + shield.grantAPIKey = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/api_key/grant', + }, + }); + /** * Invalidates an API key in Elasticsearch. * diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a1ef352056d6a..b817bcc0858a9 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -74,6 +74,7 @@ describe('Security Plugin', () => { "createAPIKey": [Function], "getCurrentUser": [Function], "getSessionInfo": [Function], + "grantAPIKeyAsInternalUser": [Function], "invalidateAPIKey": [Function], "isAuthenticated": [Function], "isProviderEnabled": [Function], diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index fc3ca4573d500..aa7e8bc26cc1f 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -8,6 +8,10 @@ import { schema } from '@kbn/config-schema'; import { canUserChangePassword } from '../../../common/model'; import { getErrorStatusCode, wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { + HTTPAuthorizationHeader, + BasicHTTPAuthorizationHeaderCredentials, +} from '../../authentication'; import { RouteDefinitionParams } from '..'; export function defineChangeUserPasswordRoutes({ @@ -43,9 +47,13 @@ export function defineChangeUserPasswordRoutes({ ? { headers: { ...request.headers, - authorization: `Basic ${Buffer.from(`${username}:${currentPassword}`).toString( - 'base64' - )}`, + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials( + username, + currentPassword || '' + ).toString() + ).toString(), }, } : request