Skip to content

Commit

Permalink
Adding authc.grantAPIKeyAsInternalUser (#60423) (#60927)
Browse files Browse the repository at this point in the history
* 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>

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>
  • Loading branch information
4 people authored Mar 23, 2020
1 parent fbe3637 commit d4e86e1
Show file tree
Hide file tree
Showing 22 changed files with 534 additions and 118 deletions.
83 changes: 83 additions & 0 deletions x-pack/plugins/security/server/authentication/api_keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions x-pack/plugins/security/server/authentication/api_keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`);
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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...
});
});
Original file line number Diff line number Diff line change
@@ -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');
}
}
Loading

0 comments on commit d4e86e1

Please sign in to comment.