Adding authc.grantAPIKeyAsInternalUser
(#60423)
* Parsing the Authorization HTTP header to grant API keys * Using HTTPAuthorizationHeader and BasicHTTPAuthorizationHeaderCredentials * Adding tests for grantAPIKey * Adding http_authentication/ folder * Removing test route * Using new classes to create the headers we pass to ES * No longer .toLowerCase() when parsing the scheme from the request * Updating snapshots * Update x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts Co-Authored-By: Aleh Zasypkin <aleh.zasypkin@gmail.com> * Updating another inline snapshot * Adding JSDoc * Renaming `grant` to `grantAsInternalUser` * Adding forgotten test. Fixing snapshot * Fixing mock * Apply suggestions from code review Co-Authored-By: Aleh Zasypkin <aleh.zasypkin@gmail.com> Co-Authored-By: Mike Côté <mikecote@users.noreply.github.com> * Using new classes for changing password * Removing unneeded asScoped call Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Mike Côté <mikecote@users.noreply.github.com>
This commit is contained in:
parent
05c995a939
commit
cca23c26fc
|
@ -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<IClusterClient>;
|
||||
|
@ -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);
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
|
@ -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...
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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(),
|
||||
|
|
|
@ -369,6 +369,24 @@ describe('setupAuthentication()', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('grantAPIKeyAsInternalUser()', () => {
|
||||
let grantAPIKeyAsInternalUser: (request: KibanaRequest) => Promise<CreateAPIKeyResult | null>;
|
||||
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,
|
||||
|
|
|
@ -28,6 +28,10 @@ export {
|
|||
CreateAPIKeyParams,
|
||||
InvalidateAPIKeyParams,
|
||||
} from './api_keys';
|
||||
export {
|
||||
BasicHTTPAuthorizationHeaderCredentials,
|
||||
HTTPAuthorizationHeader,
|
||||
} from './http_authentication';
|
||||
|
||||
interface SetupAuthenticationParams {
|
||||
http: CoreSetup['http'];
|
||||
|
@ -169,6 +173,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),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -10,7 +10,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 { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base';
|
||||
|
||||
|
@ -181,7 +181,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();
|
||||
}
|
||||
|
@ -390,7 +390,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.');
|
||||
|
@ -445,7 +447,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.');
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -74,6 +74,7 @@ describe('Security Plugin', () => {
|
|||
"createAPIKey": [Function],
|
||||
"getCurrentUser": [Function],
|
||||
"getSessionInfo": [Function],
|
||||
"grantAPIKeyAsInternalUser": [Function],
|
||||
"invalidateAPIKey": [Function],
|
||||
"isAuthenticated": [Function],
|
||||
"isProviderEnabled": [Function],
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue