[Actions] Implement generic API support for OAuth JWT authentication flow for the action connectors. (#117902)

* [Actions] Implement generic support for OAuth JWT authentication flow for the action connectors.

* fixed test

* fixed test

* fixed test

* added jsonwebtoken lib

* fixed test

* fixed test

* added docs link

* fixed due to comments

* fixed test

* fixed test

* fixed typecheck

* fixed due to comments

* fixed test

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yuliia Naumenko 2021-11-22 19:49:27 -08:00 committed by GitHub
parent 3d819705e1
commit 113688274c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 513 additions and 48 deletions

View file

@ -184,6 +184,7 @@
"@turf/distance": "6.0.1",
"@turf/helpers": "6.0.1",
"@turf/length": "^6.0.2",
"@types/jsonwebtoken": "^8.5.6",
"JSONStream": "1.3.5",
"abort-controller": "^3.0.0",
"abortcontroller-polyfill": "^1.7.3",

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('jsonwebtoken', () => ({
sign: jest.fn(),
}));
// eslint-disable-next-line import/no-extraneous-dependencies
import jwt from 'jsonwebtoken';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { createJWTAssertion } from './create_jwt_assertion';
const jwtSign = jwt.sign as jest.Mock;
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
describe('createJWTAssertion', () => {
test('creating a JWT token from provided claims with default values', () => {
jwtSign.mockReturnValueOnce('123456qwertyjwttoken');
const assertion = createJWTAssertion(mockLogger, 'test', '123456', {
audience: '1',
issuer: 'someappid',
subject: 'test@gmail.com',
});
expect(assertion).toMatchInlineSnapshot('"123456qwertyjwttoken"');
});
test('throw the exception and log the proper error if token was not get successfuly', () => {
jwtSign.mockImplementationOnce(() => {
throw new Error('{"message": "jwt wrong header", "name": "JsonWebTokenError"}');
});
const fn = () =>
createJWTAssertion(mockLogger, 'test', '123456', {
audience: '1',
issuer: 'someappid',
subject: 'test@gmail.com',
});
expect(fn).toThrowError();
expect(mockLogger.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"Unable to generate JWT token. Error: Error: {\\"message\\": \\"jwt wrong header\\", \\"name\\": \\"JsonWebTokenError\\"}",
]
`);
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import jwt, { Algorithm } from 'jsonwebtoken';
import { Logger } from '../../../../../../src/core/server';
export interface JWTClaims {
audience: string;
subject: string;
issuer: string;
expireInMilisecons?: number;
keyId?: string;
}
export function createJWTAssertion(
logger: Logger,
privateKey: string,
privateKeyPassword: string,
reservedClaims: JWTClaims,
customClaims?: Record<string, string>
): string {
const { subject, audience, issuer, expireInMilisecons, keyId } = reservedClaims;
const iat = Math.floor(Date.now() / 1000);
const headerObj = { algorithm: 'RS256' as Algorithm, ...(keyId ? { keyid: keyId } : {}) };
const payloadObj = {
sub: subject, // subject claim identifies the principal that is the subject of the JWT
aud: audience, // audience claim identifies the recipients that the JWT is intended for
iss: issuer, // issuer claim identifies the principal that issued the JWT
iat, // issued at claim identifies the time at which the JWT was issued
exp: iat + (expireInMilisecons ?? 3600), // expiration time claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing
...(customClaims ?? {}),
};
try {
const jwtToken = jwt.sign(
JSON.stringify(payloadObj),
{
key: privateKey,
passphrase: privateKeyPassword,
},
headerObj
);
return jwtToken;
} catch (error) {
const errorMessage = `Unable to generate JWT token. Error: ${error}`;
logger.warn(errorMessage);
throw new Error(errorMessage);
}
}

View file

@ -11,8 +11,8 @@ jest.mock('axios', () => ({
import axios from 'axios';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token';
import { actionsConfigMock } from '../../actions_config.mock';
import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token';
const createAxiosInstanceMock = axios.create as jest.Mock;
const axiosInstanceMock = jest.fn();
@ -122,7 +122,7 @@ describe('requestOAuthClientCredentialsToken', () => {
expect(mockLogger.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"error thrown getting the access token from https://test for clientID: 123456: {\\"error\\":\\"invalid_scope\\",\\"error_description\\":\\"AADSTS70011: The provided value for the input parameter \'scope\' is not valid.\\"}",
"error thrown getting the access token from https://test for params: {\\"scope\\":\\"test\\",\\"client_id\\":\\"123456\\",\\"client_secret\\":\\"secrert123\\"}: {\\"error\\":\\"invalid_scope\\",\\"error_description\\":\\"AADSTS70011: The provided value for the input parameter 'scope' is not valid.\\"}",
]
`);
});

View file

@ -4,66 +4,40 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import qs from 'query-string';
import axios from 'axios';
import stringify from 'json-stable-stringify';
import { Logger } from '../../../../../../src/core/server';
import { request } from './axios_utils';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { OAuthTokenResponse, requestOAuthToken } from './request_oauth_token';
import { RewriteResponseCase } from '../../../../actions/common';
export const OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE = 'client_credentials';
interface ClientCredentialsRequestParams {
export interface ClientCredentialsOAuthRequestParams {
scope?: string;
clientId?: string;
clientSecret?: string;
}
export interface ClientCredentialsResponse {
tokenType: string;
accessToken: string;
expiresIn: number;
}
const rewriteBodyRequest: RewriteResponseCase<ClientCredentialsOAuthRequestParams> = ({
clientId,
clientSecret,
...res
}) => ({
...res,
client_id: clientId,
client_secret: clientSecret,
});
export async function requestOAuthClientCredentialsToken(
tokenUrl: string,
logger: Logger,
params: ClientCredentialsRequestParams,
params: ClientCredentialsOAuthRequestParams,
configurationUtilities: ActionsConfigurationUtilities
): Promise<ClientCredentialsResponse> {
const axiosInstance = axios.create();
const { clientId, clientSecret, scope } = params;
const res = await request({
axios: axiosInstance,
url: tokenUrl,
method: 'post',
logger,
data: qs.stringify({
scope,
client_id: clientId,
client_secret: clientSecret,
grant_type: OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE,
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
): Promise<OAuthTokenResponse> {
return await requestOAuthToken<ClientCredentialsOAuthRequestParams>(
tokenUrl,
OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE,
configurationUtilities,
validateStatus: () => true,
});
if (res.status === 200) {
return {
tokenType: res.data.token_type,
accessToken: res.data.access_token,
expiresIn: res.data.expires_in,
};
} else {
const errString = stringify(res.data);
logger.warn(
`error thrown getting the access token from ${tokenUrl} for clientID: ${clientId}: ${errString}`
);
throw new Error(errString);
}
logger,
rewriteBodyRequest(params)
);
}

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('axios', () => ({
create: jest.fn(),
}));
import axios from 'axios';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../../actions_config.mock';
import { requestOAuthJWTToken } from './request_oauth_jwt_token';
const createAxiosInstanceMock = axios.create as jest.Mock;
const axiosInstanceMock = jest.fn();
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
describe('requestOAuthJWTToken', () => {
beforeEach(() => {
createAxiosInstanceMock.mockReturnValue(axiosInstanceMock);
});
test('making a token request with the required options', async () => {
const configurationUtilities = actionsConfigMock.create();
axiosInstanceMock.mockReturnValueOnce({
status: 200,
data: {
tokenType: 'Bearer',
accessToken: 'dfjsdfgdjhfgsjdf',
expiresIn: 123,
},
});
await requestOAuthJWTToken(
'https://test',
{
assertion: 'someJWTvalueishere',
clientId: 'client-id-1',
clientSecret: 'some-client-secret',
scope: 'test',
},
mockLogger,
configurationUtilities
);
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"https://test",
Object {
"data": "assertion=someJWTvalueishere&client_id=client-id-1&client_secret=some-client-secret&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&scope=test",
"headers": Object {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
},
"httpAgent": undefined,
"httpsAgent": Agent {
"_events": Object {
"free": [Function],
"newListener": [Function],
},
"_eventsCount": 2,
"_maxListeners": undefined,
"_sessionCache": Object {
"list": Array [],
"map": Object {},
},
"defaultPort": 443,
"freeSockets": Object {},
"keepAlive": false,
"keepAliveMsecs": 1000,
"maxCachedSessions": 100,
"maxFreeSockets": 256,
"maxSockets": Infinity,
"maxTotalSockets": Infinity,
"options": Object {
"path": null,
"rejectUnauthorized": true,
},
"protocol": "https:",
"requests": Object {},
"scheduling": "lifo",
"sockets": Object {},
"totalSocketCount": 0,
Symbol(kCapture): false,
},
"maxContentLength": 1000000,
"method": "post",
"proxy": false,
"timeout": 360000,
"validateStatus": [Function],
},
]
`);
});
test('throw the exception and log the proper error if token was not get successfuly', async () => {
const configurationUtilities = actionsConfigMock.create();
axiosInstanceMock.mockReturnValueOnce({
status: 400,
data: {
error: 'invalid_scope',
error_description:
"AADSTS70011: The provided value for the input parameter 'scope' is not valid.",
},
});
await expect(
requestOAuthJWTToken(
'https://test',
{
assertion: 'someJWTvalueishere',
clientId: 'client-id-1',
clientSecret: 'some-client-secret',
scope: 'test',
},
mockLogger,
configurationUtilities
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"{\\"error\\":\\"invalid_scope\\",\\"error_description\\":\\"AADSTS70011: The provided value for the input parameter \'scope\' is not valid.\\"}"'
);
expect(mockLogger.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"error thrown getting the access token from https://test for params: {\\"assertion\\":\\"someJWTvalueishere\\",\\"scope\\":\\"test\\",\\"client_id\\":\\"client-id-1\\",\\"client_secret\\":\\"some-client-secret\\"}: {\\"error\\":\\"invalid_scope\\",\\"error_description\\":\\"AADSTS70011: The provided value for the input parameter 'scope' is not valid.\\"}",
]
`);
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Logger } from '../../../../../../src/core/server';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { OAuthTokenResponse, requestOAuthToken } from './request_oauth_token';
import { RewriteResponseCase } from '../../../../actions/common';
// This is a standard for JSON Web Token (JWT) Profile
// for OAuth 2.0 Client Authentication and Authorization Grants https://datatracker.ietf.org/doc/html/rfc7523#section-8.1
export const OAUTH_JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
interface JWTOAuthRequestParams {
assertion: string;
clientId?: string;
clientSecret?: string;
scope?: string;
}
const rewriteBodyRequest: RewriteResponseCase<JWTOAuthRequestParams> = ({
clientId,
clientSecret,
...res
}) => ({
...res,
client_id: clientId,
client_secret: clientSecret,
});
export async function requestOAuthJWTToken(
tokenUrl: string,
params: JWTOAuthRequestParams,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities
): Promise<OAuthTokenResponse> {
return await requestOAuthToken<JWTOAuthRequestParams>(
tokenUrl,
OAUTH_JWT_BEARER_GRANT_TYPE,
configurationUtilities,
logger,
rewriteBodyRequest(params)
);
}

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('axios', () => ({
create: jest.fn(),
}));
import axios from 'axios';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { requestOAuthToken } from './request_oauth_token';
import { actionsConfigMock } from '../../actions_config.mock';
const createAxiosInstanceMock = axios.create as jest.Mock;
const axiosInstanceMock = jest.fn();
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
interface TestOAuthRequestParams {
someAdditionalParam?: string;
clientId?: string;
clientSecret?: string;
}
describe('requestOAuthToken', () => {
beforeEach(() => {
createAxiosInstanceMock.mockReturnValue(axiosInstanceMock);
});
test('making a token request with the required options', async () => {
const configurationUtilities = actionsConfigMock.create();
axiosInstanceMock.mockReturnValueOnce({
status: 200,
data: {
tokenType: 'Bearer',
accessToken: 'dfjsdfgdjhfgsjdf',
expiresIn: 123,
},
});
await requestOAuthToken<TestOAuthRequestParams>(
'https://test',
'test',
configurationUtilities,
mockLogger,
{
client_id: '123456',
client_secret: 'secrert123',
some_additional_param: 'test',
}
);
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"https://test",
Object {
"data": "client_id=123456&client_secret=secrert123&grant_type=test&some_additional_param=test",
"headers": Object {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
},
"httpAgent": undefined,
"httpsAgent": Agent {
"_events": Object {
"free": [Function],
"newListener": [Function],
},
"_eventsCount": 2,
"_maxListeners": undefined,
"_sessionCache": Object {
"list": Array [],
"map": Object {},
},
"defaultPort": 443,
"freeSockets": Object {},
"keepAlive": false,
"keepAliveMsecs": 1000,
"maxCachedSessions": 100,
"maxFreeSockets": 256,
"maxSockets": Infinity,
"maxTotalSockets": Infinity,
"options": Object {
"path": null,
"rejectUnauthorized": true,
},
"protocol": "https:",
"requests": Object {},
"scheduling": "lifo",
"sockets": Object {},
"totalSocketCount": 0,
Symbol(kCapture): false,
},
"maxContentLength": 1000000,
"method": "post",
"proxy": false,
"timeout": 360000,
"validateStatus": [Function],
},
]
`);
});
test('throw the exception and log the proper error if token was not get successfuly', async () => {
const configurationUtilities = actionsConfigMock.create();
axiosInstanceMock.mockReturnValueOnce({
status: 400,
data: {
error: 'invalid_scope',
error_description:
"AADSTS70011: The provided value for the input parameter 'scope' is not valid.",
},
});
await expect(
requestOAuthToken<TestOAuthRequestParams>(
'https://test',
'test',
configurationUtilities,
mockLogger,
{
client_id: '123456',
client_secret: 'secrert123',
some_additional_param: 'test',
}
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"{\\"error\\":\\"invalid_scope\\",\\"error_description\\":\\"AADSTS70011: The provided value for the input parameter \'scope\' is not valid.\\"}"'
);
expect(mockLogger.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"error thrown getting the access token from https://test for params: {\\"client_id\\":\\"123456\\",\\"client_secret\\":\\"secrert123\\",\\"some_additional_param\\":\\"test\\"}: {\\"error\\":\\"invalid_scope\\",\\"error_description\\":\\"AADSTS70011: The provided value for the input parameter 'scope' is not valid.\\"}",
]
`);
});
});

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import qs from 'query-string';
import axios from 'axios';
import stringify from 'json-stable-stringify';
import { Logger } from '../../../../../../src/core/server';
import { request } from './axios_utils';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { AsApiContract } from '../../../../actions/common';
export interface OAuthTokenResponse {
tokenType: string;
accessToken: string;
expiresIn: number;
}
export async function requestOAuthToken<T>(
tokenUrl: string,
grantType: string,
configurationUtilities: ActionsConfigurationUtilities,
logger: Logger,
bodyRequest: AsApiContract<T>
): Promise<OAuthTokenResponse> {
const axiosInstance = axios.create();
const res = await request({
axios: axiosInstance,
url: tokenUrl,
method: 'post',
logger,
data: qs.stringify({
...bodyRequest,
grant_type: grantType,
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
configurationUtilities,
validateStatus: () => true,
});
if (res.status === 200) {
return {
tokenType: res.data.token_type,
accessToken: res.data.access_token,
expiresIn: res.data.expires_in,
};
} else {
const errString = stringify(res.data);
logger.warn(
`error thrown getting the access token from ${tokenUrl} for params: ${JSON.stringify(
bodyRequest
)}: ${errString}`
);
throw new Error(errString);
}
}

View file

@ -5783,6 +5783,13 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818"
integrity sha512-sqm9g7mHlPY/43fcSNrCYfOeX9zkTTK+euO5E6+CVijSMm5tTjkVdwdqRkY3ljjIAf8679vps5jKUoJBCLsMDA==
"@types/jsonwebtoken@^8.5.6":
version "8.5.6"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.6.tgz#1913e5a61e70a192c5a444623da4901a7b1a9d42"
integrity sha512-+P3O/xC7nzVizIi5VbF34YtqSonFsdnbXBnWUCYRiKOi1f9gA4sEFvXkrGr/QVV23IbMYvcoerI7nnhDUiWXRQ==
dependencies:
"@types/node" "*"
"@types/kbn__ace@link:bazel-bin/packages/kbn-ace/npm_module_types":
version "0.0.0"
uid ""