[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:
Ying Mao 2022-05-12 12:29:06 -04:00 committed by GitHub
parent e9b1d3834a
commit b932dcab0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 490 additions and 65 deletions

View file

@ -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,
});

View file

@ -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',
});
}

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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,

View file

@ -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,
});

View file

@ -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(

View file

@ -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
);
}

View file

@ -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({

View file

@ -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();
});
});
});

View file

@ -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);
}
);
}