mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Connectors] ConnectorTokenClient
improvements (#131955)
* Throwing error if service now access token is null. Properly returning rejected promise * Setting time to calculate token expiration to before the token is created * Returning null access token if stored access token has invalid expiresAt date * Adding response interceptor to delete connector token if using access token returns 4xx error * Adding test for tokenRequestDate * Handling 4xx errors in the response * Fixing unit tests * Fixing types
This commit is contained in:
parent
e9b1d3834a
commit
b932dcab0b
11 changed files with 490 additions and 65 deletions
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import sinon from 'sinon';
|
||||
import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
|
||||
import { ConnectorTokenClient } from './connector_token_client';
|
||||
|
@ -23,7 +24,13 @@ const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient();
|
|||
|
||||
let connectorTokenClient: ConnectorTokenClient;
|
||||
|
||||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
beforeAll(() => {
|
||||
clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z'));
|
||||
});
|
||||
beforeEach(() => {
|
||||
clock.reset();
|
||||
jest.resetAllMocks();
|
||||
connectorTokenClient = new ConnectorTokenClient({
|
||||
unsecuredSavedObjectsClient,
|
||||
|
@ -31,6 +38,7 @@ beforeEach(() => {
|
|||
logger,
|
||||
});
|
||||
});
|
||||
afterAll(() => clock.restore());
|
||||
|
||||
describe('create()', () => {
|
||||
test('creates connector_token with all given properties', async () => {
|
||||
|
@ -131,7 +139,7 @@ describe('get()', () => {
|
|||
expect(result).toEqual({ connectorToken: null, hasErrors: false });
|
||||
});
|
||||
|
||||
test('return null and log the error if unsecuredSavedObjectsClient thows an error', async () => {
|
||||
test('return null and log the error if unsecuredSavedObjectsClient throws an error', async () => {
|
||||
unsecuredSavedObjectsClient.find.mockRejectedValueOnce(new Error('Fail'));
|
||||
|
||||
const result = await connectorTokenClient.get({
|
||||
|
@ -145,7 +153,7 @@ describe('get()', () => {
|
|||
expect(result).toEqual({ connectorToken: null, hasErrors: true });
|
||||
});
|
||||
|
||||
test('return null and log the error if encryptedSavedObjectsClient decrypt method thows an error', async () => {
|
||||
test('return null and log the error if encryptedSavedObjectsClient decrypt method throws an error', async () => {
|
||||
const expectedResult = {
|
||||
total: 1,
|
||||
per_page: 10,
|
||||
|
@ -178,6 +186,47 @@ describe('get()', () => {
|
|||
]);
|
||||
expect(result).toEqual({ connectorToken: null, hasErrors: true });
|
||||
});
|
||||
|
||||
test('return null and log the error if expiresAt is NaN', async () => {
|
||||
const expectedResult = {
|
||||
total: 1,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
saved_objects: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'connector_token',
|
||||
attributes: {
|
||||
connectorId: '123',
|
||||
tokenType: 'access_token',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: 'yo',
|
||||
},
|
||||
score: 1,
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult);
|
||||
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({
|
||||
id: '1',
|
||||
type: 'connector_token',
|
||||
references: [],
|
||||
attributes: {
|
||||
token: 'testtokenvalue',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await connectorTokenClient.get({
|
||||
connectorId: '123',
|
||||
tokenType: 'access_token',
|
||||
});
|
||||
|
||||
expect(logger.error.mock.calls[0]).toMatchObject([
|
||||
`Failed to get connector_token for connectorId "123" and tokenType: "access_token". Error: expiresAt is not a valid Date "yo"`,
|
||||
]);
|
||||
expect(result).toEqual({ connectorToken: null, hasErrors: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update()', () => {
|
||||
|
@ -375,12 +424,60 @@ describe('updateOrReplace()', () => {
|
|||
connectorId: '1',
|
||||
token: null,
|
||||
newToken: 'newToken',
|
||||
tokenRequestDate: undefined as unknown as number,
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: false,
|
||||
});
|
||||
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
|
||||
expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe(
|
||||
'newToken'
|
||||
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith(
|
||||
'connector_token',
|
||||
{
|
||||
connectorId: '1',
|
||||
createdAt: '2021-01-01T12:00:00.000Z',
|
||||
expiresAt: '2021-01-01T12:16:40.000Z',
|
||||
token: 'newToken',
|
||||
tokenType: 'access_token',
|
||||
updatedAt: '2021-01-01T12:00:00.000Z',
|
||||
},
|
||||
{ id: 'mock-saved-object-id' }
|
||||
);
|
||||
|
||||
expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled();
|
||||
expect(unsecuredSavedObjectsClient.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses tokenRequestDate to determine expire time if provided', async () => {
|
||||
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
|
||||
id: '1',
|
||||
type: 'connector_token',
|
||||
attributes: {
|
||||
connectorId: '123',
|
||||
tokenType: 'access_token',
|
||||
token: 'testtokenvalue',
|
||||
expiresAt: new Date('2021-01-01T08:00:00.000Z').toISOString(),
|
||||
},
|
||||
references: [],
|
||||
});
|
||||
await connectorTokenClient.updateOrReplace({
|
||||
connectorId: '1',
|
||||
token: null,
|
||||
newToken: 'newToken',
|
||||
tokenRequestDate: new Date('2021-03-03T00:00:00.000Z').getTime(),
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: false,
|
||||
});
|
||||
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1);
|
||||
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith(
|
||||
'connector_token',
|
||||
{
|
||||
connectorId: '1',
|
||||
createdAt: '2021-01-01T12:00:00.000Z',
|
||||
expiresAt: '2021-03-03T00:16:40.000Z',
|
||||
token: 'newToken',
|
||||
tokenType: 'access_token',
|
||||
updatedAt: '2021-01-01T12:00:00.000Z',
|
||||
},
|
||||
{ id: 'mock-saved-object-id' }
|
||||
);
|
||||
|
||||
expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled();
|
||||
|
@ -434,6 +531,7 @@ describe('updateOrReplace()', () => {
|
|||
connectorId: '1',
|
||||
token: null,
|
||||
newToken: 'newToken',
|
||||
tokenRequestDate: Date.now(),
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: true,
|
||||
});
|
||||
|
@ -483,6 +581,7 @@ describe('updateOrReplace()', () => {
|
|||
expiresAt: new Date().toISOString(),
|
||||
},
|
||||
newToken: 'newToken',
|
||||
tokenRequestDate: Date.now(),
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: true,
|
||||
});
|
||||
|
|
|
@ -38,6 +38,7 @@ interface UpdateOrReplaceOptions {
|
|||
token: ConnectorToken | null;
|
||||
newToken: string;
|
||||
expiresInSec: number;
|
||||
tokenRequestDate: number;
|
||||
deleteExisting: boolean;
|
||||
}
|
||||
export class ConnectorTokenClient {
|
||||
|
@ -195,6 +196,7 @@ export class ConnectorTokenClient {
|
|||
return { hasErrors: false, connectorToken: null };
|
||||
}
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
const {
|
||||
attributes: { token },
|
||||
|
@ -203,14 +205,7 @@ export class ConnectorTokenClient {
|
|||
connectorTokensResult[0].id
|
||||
);
|
||||
|
||||
return {
|
||||
hasErrors: false,
|
||||
connectorToken: {
|
||||
id: connectorTokensResult[0].id,
|
||||
...connectorTokensResult[0].attributes,
|
||||
token,
|
||||
},
|
||||
};
|
||||
accessToken = token;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${
|
||||
|
@ -219,6 +214,24 @@ export class ConnectorTokenClient {
|
|||
);
|
||||
return { hasErrors: true, connectorToken: null };
|
||||
}
|
||||
|
||||
if (isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt))) {
|
||||
this.logger.error(
|
||||
`Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${
|
||||
tokenType ?? 'access_token'
|
||||
}". Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"`
|
||||
);
|
||||
return { hasErrors: true, connectorToken: null };
|
||||
}
|
||||
|
||||
return {
|
||||
hasErrors: false,
|
||||
connectorToken: {
|
||||
id: connectorTokensResult[0].id,
|
||||
...connectorTokensResult[0].attributes,
|
||||
token: accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -258,9 +271,11 @@ export class ConnectorTokenClient {
|
|||
token,
|
||||
newToken,
|
||||
expiresInSec,
|
||||
tokenRequestDate,
|
||||
deleteExisting,
|
||||
}: UpdateOrReplaceOptions) {
|
||||
expiresInSec = expiresInSec ?? 3600;
|
||||
tokenRequestDate = tokenRequestDate ?? Date.now();
|
||||
if (token === null) {
|
||||
if (deleteExisting) {
|
||||
await this.deleteConnectorTokens({
|
||||
|
@ -272,14 +287,14 @@ export class ConnectorTokenClient {
|
|||
await this.create({
|
||||
connectorId,
|
||||
token: newToken,
|
||||
expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(),
|
||||
expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(),
|
||||
tokenType: 'access_token',
|
||||
});
|
||||
} else {
|
||||
await this.update({
|
||||
id: token.id!.toString(),
|
||||
token: newToken,
|
||||
expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(),
|
||||
expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(),
|
||||
tokenType: 'access_token',
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import sinon from 'sinon';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { asyncForEach } from '@kbn/std';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
|
@ -20,7 +21,15 @@ const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
|||
const configurationUtilities = actionsConfigMock.create();
|
||||
const connectorTokenClient = connectorTokenClientMock.create();
|
||||
|
||||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
describe('getOAuthClientCredentialsAccessToken', () => {
|
||||
beforeAll(() => {
|
||||
clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z'));
|
||||
});
|
||||
beforeEach(() => clock.reset());
|
||||
afterAll(() => clock.restore());
|
||||
|
||||
const getOAuthClientCredentialsAccessTokenOpts = {
|
||||
connectorId: '123',
|
||||
logger,
|
||||
|
@ -52,8 +61,8 @@ describe('getOAuthClientCredentialsAccessToken', () => {
|
|||
connectorId: '123',
|
||||
tokenType: 'access_token',
|
||||
token: 'testtokenvalue',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 10000000000).toISOString(),
|
||||
createdAt: new Date('2021-01-01T08:00:00.000Z').toISOString(),
|
||||
expiresAt: new Date('2021-01-02T13:00:00.000Z').toISOString(),
|
||||
},
|
||||
});
|
||||
const accessToken = await getOAuthClientCredentialsAccessToken(
|
||||
|
@ -95,14 +104,15 @@ describe('getOAuthClientCredentialsAccessToken', () => {
|
|||
connectorId: '123',
|
||||
token: null,
|
||||
newToken: 'access_token brandnewaccesstoken',
|
||||
tokenRequestDate: 1609502400000,
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('creates new assertion if stored access token exists but is expired', async () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() - 100).toISOString();
|
||||
const createdAt = new Date('2021-01-01T08:00:00.000Z').toISOString();
|
||||
const expiresAt = new Date('2021-01-01T09:00:00.000Z').toISOString();
|
||||
connectorTokenClient.get.mockResolvedValueOnce({
|
||||
hasErrors: false,
|
||||
connectorToken: {
|
||||
|
@ -147,6 +157,7 @@ describe('getOAuthClientCredentialsAccessToken', () => {
|
|||
expiresAt,
|
||||
},
|
||||
newToken: 'access_token brandnewaccesstoken',
|
||||
tokenRequestDate: 1609502400000,
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: false,
|
||||
});
|
||||
|
@ -210,6 +221,7 @@ describe('getOAuthClientCredentialsAccessToken', () => {
|
|||
(requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({
|
||||
tokenType: 'access_token',
|
||||
accessToken: 'brandnewaccesstoken',
|
||||
tokenRequestDate: 1609502400000,
|
||||
expiresIn: 1000,
|
||||
});
|
||||
connectorTokenClient.updateOrReplace.mockRejectedValueOnce(new Error('updateOrReplace error'));
|
||||
|
@ -268,6 +280,7 @@ describe('getOAuthClientCredentialsAccessToken', () => {
|
|||
(requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({
|
||||
tokenType: 'access_token',
|
||||
accessToken: 'brandnewaccesstoken',
|
||||
tokenRequestDate: 1609502400000,
|
||||
expiresIn: 1000,
|
||||
});
|
||||
|
||||
|
|
|
@ -62,6 +62,9 @@ export const getOAuthClientCredentialsAccessToken = async ({
|
|||
}
|
||||
|
||||
if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) {
|
||||
// Save the time before requesting token so we can use it to calculate expiration
|
||||
const requestTokenStart = Date.now();
|
||||
|
||||
// request access token with jwt assertion
|
||||
const tokenResult = await requestOAuthClientCredentialsToken(
|
||||
tokenUrl,
|
||||
|
@ -82,6 +85,7 @@ export const getOAuthClientCredentialsAccessToken = async ({
|
|||
connectorId,
|
||||
token: connectorToken,
|
||||
newToken: accessToken,
|
||||
tokenRequestDate: requestTokenStart,
|
||||
expiresInSec: tokenResult.expiresIn,
|
||||
deleteExisting: hasErrors,
|
||||
});
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import sinon from 'sinon';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { asyncForEach } from '@kbn/std';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
|
@ -24,7 +25,15 @@ const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
|||
const configurationUtilities = actionsConfigMock.create();
|
||||
const connectorTokenClient = connectorTokenClientMock.create();
|
||||
|
||||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
describe('getOAuthJwtAccessToken', () => {
|
||||
beforeAll(() => {
|
||||
clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z'));
|
||||
});
|
||||
beforeEach(() => clock.reset());
|
||||
afterAll(() => clock.restore());
|
||||
|
||||
const getOAuthJwtAccessTokenOpts = {
|
||||
connectorId: '123',
|
||||
logger,
|
||||
|
@ -58,8 +67,8 @@ describe('getOAuthJwtAccessToken', () => {
|
|||
connectorId: '123',
|
||||
tokenType: 'access_token',
|
||||
token: 'testtokenvalue',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 10000000000).toISOString(),
|
||||
createdAt: new Date('2021-01-01T08:00:00.000Z').toISOString(),
|
||||
expiresAt: new Date('2021-01-02T13:00:00.000Z').toISOString(),
|
||||
},
|
||||
});
|
||||
const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts);
|
||||
|
@ -105,14 +114,15 @@ describe('getOAuthJwtAccessToken', () => {
|
|||
connectorId: '123',
|
||||
token: null,
|
||||
newToken: 'access_token brandnewaccesstoken',
|
||||
tokenRequestDate: 1609502400000,
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('creates new assertion if stored access token exists but is expired', async () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() - 100).toISOString();
|
||||
const createdAt = new Date('2021-01-01T08:00:00.000Z').toISOString();
|
||||
const expiresAt = new Date('2021-01-01T09:00:00.000Z').toISOString();
|
||||
connectorTokenClient.get.mockResolvedValueOnce({
|
||||
hasErrors: false,
|
||||
connectorToken: {
|
||||
|
@ -161,6 +171,7 @@ describe('getOAuthJwtAccessToken', () => {
|
|||
createdAt,
|
||||
expiresAt,
|
||||
},
|
||||
tokenRequestDate: 1609502400000,
|
||||
newToken: 'access_token brandnewaccesstoken',
|
||||
expiresInSec: 1000,
|
||||
deleteExisting: false,
|
||||
|
|
|
@ -72,6 +72,9 @@ export const getOAuthJwtAccessToken = async ({
|
|||
keyId: jwtKeyId,
|
||||
});
|
||||
|
||||
// Save the time before requesting token so we can use it to calculate expiration
|
||||
const requestTokenStart = Date.now();
|
||||
|
||||
// request access token with jwt assertion
|
||||
const tokenResult = await requestOAuthJWTToken(
|
||||
tokenUrl,
|
||||
|
@ -92,6 +95,7 @@ export const getOAuthJwtAccessToken = async ({
|
|||
connectorId,
|
||||
token: connectorToken,
|
||||
newToken: accessToken,
|
||||
tokenRequestDate: requestTokenStart,
|
||||
expiresInSec: tokenResult.expiresIn,
|
||||
deleteExisting: hasErrors,
|
||||
});
|
||||
|
|
|
@ -5,10 +5,21 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { sendEmail } from './send_email';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { ProxySettings } from '../../types';
|
||||
import { actionsConfigMock } from '../../actions_config.mock';
|
||||
import { CustomHostSettings } from '../../config';
|
||||
import { sendEmailGraphApi } from './send_email_graph_api';
|
||||
import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token';
|
||||
import { connectorTokenClientMock } from './connector_token_client.mock';
|
||||
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./send_email_graph_api', () => ({
|
||||
sendEmailGraphApi: jest.fn(),
|
||||
}));
|
||||
|
@ -16,36 +27,32 @@ jest.mock('./get_oauth_client_credentials_access_token', () => ({
|
|||
getOAuthClientCredentialsAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { sendEmail } from './send_email';
|
||||
import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { ProxySettings } from '../../types';
|
||||
import { actionsConfigMock } from '../../actions_config.mock';
|
||||
import { CustomHostSettings } from '../../config';
|
||||
import { sendEmailGraphApi } from './send_email_graph_api';
|
||||
import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token';
|
||||
import { ConnectorTokenClient } from './connector_token_client';
|
||||
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
|
||||
jest.mock('axios');
|
||||
const mockAxiosInstanceInterceptor = {
|
||||
request: { eject: jest.fn(), use: jest.fn() },
|
||||
response: { eject: jest.fn(), use: jest.fn() },
|
||||
};
|
||||
|
||||
const createTransportMock = nodemailer.createTransport as jest.Mock;
|
||||
const sendMailMockResult = { result: 'does not matter' };
|
||||
const sendMailMock = jest.fn();
|
||||
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
||||
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
|
||||
const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient();
|
||||
|
||||
const connectorTokenClient = new ConnectorTokenClient({
|
||||
unsecuredSavedObjectsClient,
|
||||
encryptedSavedObjectsClient,
|
||||
logger: mockLogger,
|
||||
});
|
||||
const connectorTokenClient = connectorTokenClientMock.create();
|
||||
|
||||
describe('send_email module', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
createTransportMock.mockReturnValue({ sendMail: sendMailMock });
|
||||
sendMailMock.mockResolvedValue(sendMailMockResult);
|
||||
|
||||
axios.create = jest.fn(() => {
|
||||
const actual = jest.requireActual('axios');
|
||||
return {
|
||||
...actual.create,
|
||||
interceptors: mockAxiosInstanceInterceptor,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
test('handles authenticated email using service', async () => {
|
||||
|
@ -125,6 +132,7 @@ describe('send_email module', () => {
|
|||
|
||||
delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities;
|
||||
sendEmailGraphApiMock.mock.calls[0].pop();
|
||||
sendEmailGraphApiMock.mock.calls[0].pop();
|
||||
expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
|
@ -647,6 +655,83 @@ describe('send_email module', () => {
|
|||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('deletes saved access tokens if 4xx response received', async () => {
|
||||
const createAxiosInstanceMock = axios.create as jest.Mock;
|
||||
const sendEmailOptions = getSendEmailOptions({
|
||||
transport: {
|
||||
service: 'exchange_server',
|
||||
clientId: '123456',
|
||||
tenantId: '98765',
|
||||
clientSecret: 'sdfhkdsjhfksdjfh',
|
||||
},
|
||||
});
|
||||
(getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce(
|
||||
'Bearer clienttokentokentoken'
|
||||
);
|
||||
|
||||
await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient);
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledWith();
|
||||
expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1);
|
||||
|
||||
const mockResponseCallback = (mockAxiosInstanceInterceptor.response.use as jest.Mock).mock
|
||||
.calls[0][1];
|
||||
|
||||
const errorResponse = {
|
||||
response: {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
data: {
|
||||
error: {
|
||||
message: 'Insufficient rights to query records',
|
||||
detail: 'Field(s) present in the query do not have permission to be read',
|
||||
},
|
||||
status: 'failure',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse);
|
||||
|
||||
expect(connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({
|
||||
connectorId: '1',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not delete saved access token if not 4xx error response received', async () => {
|
||||
const createAxiosInstanceMock = axios.create as jest.Mock;
|
||||
const sendEmailOptions = getSendEmailOptions({
|
||||
transport: {
|
||||
service: 'exchange_server',
|
||||
clientId: '123456',
|
||||
tenantId: '98765',
|
||||
clientSecret: 'sdfhkdsjhfksdjfh',
|
||||
},
|
||||
});
|
||||
(getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce(
|
||||
'Bearer clienttokentokentoken'
|
||||
);
|
||||
|
||||
await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient);
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledWith();
|
||||
expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1);
|
||||
|
||||
const mockResponseCallback = (mockAxiosInstanceInterceptor.response.use as jest.Mock).mock
|
||||
.calls[0][1];
|
||||
|
||||
const errorResponse = {
|
||||
response: {
|
||||
status: 500,
|
||||
statusText: 'Server error',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse);
|
||||
|
||||
expect(connectorTokenClient.deleteConnectorTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function getSendEmailOptions(
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
// info on nodemailer: https://nodemailer.com/about/
|
||||
import nodemailer from 'nodemailer';
|
||||
import { default as MarkdownIt } from 'markdown-it';
|
||||
|
@ -77,7 +78,7 @@ export async function sendEmail(
|
|||
}
|
||||
|
||||
// send an email using MS Exchange Graph API
|
||||
async function sendEmailWithExchange(
|
||||
export async function sendEmailWithExchange(
|
||||
logger: Logger,
|
||||
options: SendEmailOptions,
|
||||
messageHTML: string,
|
||||
|
@ -113,6 +114,30 @@ async function sendEmailWithExchange(
|
|||
Authorization: accessToken,
|
||||
};
|
||||
|
||||
const axiosInstance = axios.create();
|
||||
axiosInstance.interceptors.response.use(
|
||||
async (response: AxiosResponse) => {
|
||||
// Look for 4xx errors that indicate something is wrong with the request
|
||||
// We don't know for sure that it is an access token issue but remove saved
|
||||
// token just to be sure
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
await connectorTokenClient.deleteConnectorTokens({ connectorId });
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const statusCode = error?.response?.status;
|
||||
|
||||
// Look for 4xx errors that indicate something is wrong with the request
|
||||
// We don't know for sure that it is an access token issue but remove saved
|
||||
// token just to be sure
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
await connectorTokenClient.deleteConnectorTokens({ connectorId });
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return await sendEmailGraphApi(
|
||||
{
|
||||
options,
|
||||
|
@ -121,7 +146,8 @@ async function sendEmailWithExchange(
|
|||
graphApiUrl: configurationUtilities.getMicrosoftGraphApiUrl(),
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
axiosInstance
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
// @ts-expect-error missing type def
|
||||
import stringify from 'json-stringify-safe';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { request } from './axios_utils';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
|
@ -25,11 +25,13 @@ const MICROSOFT_GRAPH_API_HOST = 'https://graph.microsoft.com/v1.0';
|
|||
export async function sendEmailGraphApi(
|
||||
sendEmailOptions: SendEmailGraphApiOptions,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
axiosInstance?: AxiosInstance
|
||||
): Promise<AxiosResponse> {
|
||||
const { options, headers, messageHTML, graphApiUrl } = sendEmailOptions;
|
||||
|
||||
const axiosInstance = axios.create();
|
||||
// Create a new axios instance if one is not provided
|
||||
axiosInstance = axiosInstance ?? axios.create();
|
||||
|
||||
// POST /users/{id | userPrincipalName}/sendMail
|
||||
const res = await request({
|
||||
|
|
|
@ -192,17 +192,6 @@ describe('utils', () => {
|
|||
});
|
||||
|
||||
test('creates axios instance with interceptor when isOAuth is true and OAuth fields are defined', async () => {
|
||||
connectorTokenClient.get.mockResolvedValueOnce({
|
||||
hasErrors: false,
|
||||
connectorToken: {
|
||||
id: '1',
|
||||
connectorId: '123',
|
||||
tokenType: 'access_token',
|
||||
token: 'testtokenvalue',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 10000000000).toISOString(),
|
||||
},
|
||||
});
|
||||
getAxiosInstance({
|
||||
connectorId: '123',
|
||||
logger,
|
||||
|
@ -260,5 +249,169 @@ describe('utils', () => {
|
|||
connectorTokenClient,
|
||||
});
|
||||
});
|
||||
|
||||
test('throws expected error if getOAuthJwtAccessToken returns null access token', async () => {
|
||||
getAxiosInstance({
|
||||
connectorId: '123',
|
||||
logger,
|
||||
configurationUtilities,
|
||||
credentials: {
|
||||
config: {
|
||||
apiUrl: 'https://servicenow',
|
||||
usesTableApi: true,
|
||||
isOAuth: true,
|
||||
clientId: 'clientId',
|
||||
jwtKeyId: 'jwtKeyId',
|
||||
userIdentifierValue: 'userIdentifierValue',
|
||||
},
|
||||
secrets: {
|
||||
clientSecret: 'clientSecret',
|
||||
privateKey: 'privateKey',
|
||||
privateKeyPassword: null,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
},
|
||||
snServiceUrl: 'https://dev23432523.service-now.com',
|
||||
connectorTokenClient,
|
||||
});
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledWith();
|
||||
expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1);
|
||||
|
||||
(getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce(null);
|
||||
|
||||
const mockRequestCallback = (axiosInstanceMock.interceptors.request.use as jest.Mock).mock
|
||||
.calls[0][0];
|
||||
|
||||
await expect(() =>
|
||||
mockRequestCallback({ headers: {} })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unable to retrieve access token for connectorId: 123"`
|
||||
);
|
||||
|
||||
expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalledWith({
|
||||
connectorId: '123',
|
||||
logger,
|
||||
configurationUtilities,
|
||||
credentials: {
|
||||
config: {
|
||||
clientId: 'clientId',
|
||||
jwtKeyId: 'jwtKeyId',
|
||||
userIdentifierValue: 'userIdentifierValue',
|
||||
},
|
||||
secrets: {
|
||||
clientSecret: 'clientSecret',
|
||||
privateKey: 'privateKey',
|
||||
privateKeyPassword: null,
|
||||
},
|
||||
},
|
||||
tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do',
|
||||
connectorTokenClient,
|
||||
});
|
||||
});
|
||||
|
||||
test('deletes saved access tokens if 4xx response received', async () => {
|
||||
getAxiosInstance({
|
||||
connectorId: '123',
|
||||
logger,
|
||||
configurationUtilities,
|
||||
credentials: {
|
||||
config: {
|
||||
apiUrl: 'https://servicenow',
|
||||
usesTableApi: true,
|
||||
isOAuth: true,
|
||||
clientId: 'clientId',
|
||||
jwtKeyId: 'jwtKeyId',
|
||||
userIdentifierValue: 'userIdentifierValue',
|
||||
},
|
||||
secrets: {
|
||||
clientSecret: 'clientSecret',
|
||||
privateKey: 'privateKey',
|
||||
privateKeyPassword: null,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
},
|
||||
snServiceUrl: 'https://dev23432523.service-now.com',
|
||||
connectorTokenClient,
|
||||
});
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledWith();
|
||||
expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1);
|
||||
expect(axiosInstanceMock.interceptors.response.use).toHaveBeenCalledTimes(1);
|
||||
|
||||
(getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken');
|
||||
|
||||
const mockResponseCallback = (axiosInstanceMock.interceptors.response.use as jest.Mock).mock
|
||||
.calls[0][1];
|
||||
|
||||
const errorResponse = {
|
||||
response: {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
data: {
|
||||
error: {
|
||||
message: 'Insufficient rights to query records',
|
||||
detail: 'Field(s) present in the query do not have permission to be read',
|
||||
},
|
||||
status: 'failure',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse);
|
||||
|
||||
expect(connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({
|
||||
connectorId: '123',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not delete saved access token if not 4xx error response received', async () => {
|
||||
getAxiosInstance({
|
||||
connectorId: '123',
|
||||
logger,
|
||||
configurationUtilities,
|
||||
credentials: {
|
||||
config: {
|
||||
apiUrl: 'https://servicenow',
|
||||
usesTableApi: true,
|
||||
isOAuth: true,
|
||||
clientId: 'clientId',
|
||||
jwtKeyId: 'jwtKeyId',
|
||||
userIdentifierValue: 'userIdentifierValue',
|
||||
},
|
||||
secrets: {
|
||||
clientSecret: 'clientSecret',
|
||||
privateKey: 'privateKey',
|
||||
privateKeyPassword: null,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
},
|
||||
snServiceUrl: 'https://dev23432523.service-now.com',
|
||||
connectorTokenClient,
|
||||
});
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAxiosInstanceMock).toHaveBeenCalledWith();
|
||||
expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1);
|
||||
expect(axiosInstanceMock.interceptors.response.use).toHaveBeenCalledTimes(1);
|
||||
|
||||
(getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken');
|
||||
|
||||
const mockResponseCallback = (axiosInstanceMock.interceptors.response.use as jest.Mock).mock
|
||||
.calls[0][1];
|
||||
|
||||
const errorResponse = {
|
||||
response: {
|
||||
status: 500,
|
||||
statusText: 'Server error',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse);
|
||||
|
||||
expect(connectorTokenClient.deleteConnectorTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import {
|
||||
ExternalServiceCredentials,
|
||||
|
@ -83,12 +83,12 @@ export const throwIfSubActionIsNotSupported = ({
|
|||
};
|
||||
|
||||
export interface GetAxiosInstanceOpts {
|
||||
connectorId?: string;
|
||||
connectorId: string;
|
||||
logger: Logger;
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
credentials: ExternalServiceCredentials;
|
||||
snServiceUrl: string;
|
||||
connectorTokenClient?: ConnectorTokenClientContract;
|
||||
connectorTokenClient: ConnectorTokenClientContract;
|
||||
}
|
||||
|
||||
export const getAxiosInstance = ({
|
||||
|
@ -134,15 +134,28 @@ export const getAxiosInstance = ({
|
|||
tokenUrl: `${snServiceUrl}/oauth_token.do`,
|
||||
connectorTokenClient,
|
||||
});
|
||||
|
||||
if (accessToken) {
|
||||
axiosConfig.headers = { ...axiosConfig.headers, Authorization: accessToken };
|
||||
if (!accessToken) {
|
||||
throw new Error(`Unable to retrieve access token for connectorId: ${connectorId}`);
|
||||
}
|
||||
|
||||
axiosConfig.headers = { ...axiosConfig.headers, Authorization: accessToken };
|
||||
return axiosConfig;
|
||||
},
|
||||
(error) => {
|
||||
Promise.reject(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error) => {
|
||||
const statusCode = error?.response?.status;
|
||||
|
||||
// Look for 4xx errors that indicate something is wrong with the request
|
||||
// We don't know for sure that it is an access token issue but remove saved
|
||||
// token just to be sure
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
await connectorTokenClient.deleteConnectorTokens({ connectorId });
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue