Skip to content

Commit

Permalink
[Stack Connectors][Microsoft Defender] Improve management of OAuth to…
Browse files Browse the repository at this point in the history
…ken to Microsoft's API (elastic#208120)

## Summary

- Add re-try logic to requests to Microsoft's API so that if a `401`
(Unauthorized) is encountered, the existing cached access token is
regenerated and the request is tried again.
- This change should handle (edge) cases where the connector's access
settings could be updated and connector would continue to use the cached
token

(cherry picked from commit 83e2a16)
  • Loading branch information
paul-tavares committed Jan 28, 2025
1 parent 0ae9293 commit d9857fd
Show file tree
Hide file tree
Showing 3 changed files with 290 additions and 142 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
import type { AxiosError } from 'axios';
import type { AxiosError, AxiosResponse } from 'axios';
import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
import { OAuthTokenManager } from './o_auth_token_manager';
Expand Down Expand Up @@ -52,7 +52,6 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector<
params: ServiceParams<MicrosoftDefenderEndpointConfig, MicrosoftDefenderEndpointSecrets>
) {
super(params);

this.oAuthToken = new OAuthTokenManager({
...params,
apiRequest: async (...args) => this.request(...args),
Expand Down Expand Up @@ -110,19 +109,45 @@ export class MicrosoftDefenderEndpointConnector extends SubActionConnector<
): Promise<R> {
this.logger.debug(() => `Request:\n${JSON.stringify(req, null, 2)}`);

const bearerAccessToken = await this.oAuthToken.get(connectorUsageCollector);
const response = await this.request<R>(
{
...req,
// We don't validate responses from Microsoft API's because we do not want failures for cases
// where the external system might add/remove/change values in the response that we have no
// control over.
responseSchema:
MicrosoftDefenderEndpointDoNotValidateResponseSchema as unknown as SubActionRequestParams<R>['responseSchema'],
headers: { Authorization: `Bearer ${bearerAccessToken}` },
const requestOptions: SubActionRequestParams<R> = {
...req,
// We don't validate responses from Microsoft API's because we do not want failures for cases
// where the external system might add/remove/change values in the response that we have no
// control over.
responseSchema:
MicrosoftDefenderEndpointDoNotValidateResponseSchema as unknown as SubActionRequestParams<R>['responseSchema'],
headers: {
Authorization: `Bearer ${await this.oAuthToken.get(connectorUsageCollector)}`,
},
connectorUsageCollector
);
};
let response: AxiosResponse<R>;
let was401RetryDone = false;

try {
response = await this.request<R>(requestOptions, connectorUsageCollector);
} catch (err) {
if (was401RetryDone) {
throw err;
}

this.logger.debug("API call failed! Determining if it's one we can retry");

// If error was a 401, then for some reason the token used was not valid (ex. perhaps the connector's credentials
// were updated). IN this case, we will try again by ensuring a new token is re-generated
if (err.message.includes('Status code: 401')) {
this.logger.warn(
`Received HTTP 401 (Unauthorized). Re-generating new access token and trying again`
);
was401RetryDone = true;
await this.oAuthToken.generateNew(connectorUsageCollector);
requestOptions.headers!.Authorization = `Bearer ${await this.oAuthToken.get(
connectorUsageCollector
)}`;
response = await this.request<R>(requestOptions, connectorUsageCollector);
} else {
throw err;
}
}

return response.data;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,66 +27,99 @@ describe('Microsoft Defender for Endpoint oAuth token manager', () => {
});
});

it('should call MS api to generate new token', async () => {
await msOAuthManagerMock.get(testMock.usageCollector);
describe('#get()', () => {
it('should call MS api to generate new token', async () => {
await msOAuthManagerMock.get(testMock.usageCollector);

expect(testMock.instanceMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: `${testMock.options.config.oAuthServerUrl}/${testMock.options.config.tenantId}/oauth2/v2.0/token`,
method: 'POST',
data: {
grant_type: 'client_credentials',
client_id: testMock.options.config.clientId,
scope: testMock.options.config.oAuthScope,
client_secret: testMock.options.secrets.clientSecret,
},
}),
testMock.usageCollector
);
});
expect(testMock.instanceMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: `${testMock.options.config.oAuthServerUrl}/${testMock.options.config.tenantId}/oauth2/v2.0/token`,
method: 'POST',
data: {
grant_type: 'client_credentials',
client_id: testMock.options.config.clientId,
scope: testMock.options.config.oAuthScope,
client_secret: testMock.options.secrets.clientSecret,
},
}),
testMock.usageCollector
);
});

it('should use cached token when one exists', async () => {
const {
connectorId,
token,
expiresAt: expiresAtMillis,
tokenType,
} = microsoftDefenderEndpointConnectorMocks.createConnectorToken();
await connectorTokenManagerClientMock.create({
connectorId,
token,
expiresAtMillis,
tokenType,
});
await msOAuthManagerMock.get(testMock.usageCollector);

it('should use cached token when one exists', async () => {
const {
connectorId,
token,
expiresAt: expiresAtMillis,
tokenType,
} = microsoftDefenderEndpointConnectorMocks.createConnectorToken();
await connectorTokenManagerClientMock.create({
connectorId,
token,
expiresAtMillis,
tokenType,
expect(testMock.instanceMock.request).not.toHaveBeenCalled();
expect(connectorTokenManagerClientMock.get).toHaveBeenCalledWith({
connectorId: '1',
tokenType: 'access_token',
});
});
await msOAuthManagerMock.get(testMock.usageCollector);

expect(testMock.instanceMock.request).not.toHaveBeenCalled();
expect(connectorTokenManagerClientMock.get).toHaveBeenCalledWith({
connectorId: '1',
tokenType: 'access_token',
it('should call MS API to generate new token when the cached token is expired', async () => {
const { connectorId, token, tokenType } =
microsoftDefenderEndpointConnectorMocks.createConnectorToken();
await connectorTokenManagerClientMock.create({
connectorId,
token,
expiresAtMillis: '2024-01-16T13:02:43.494Z',
tokenType,
});
await msOAuthManagerMock.get(testMock.usageCollector);

expect(connectorTokenManagerClientMock.get).toHaveBeenCalledWith({
connectorId: '1',
tokenType: 'access_token',
});
expect(testMock.instanceMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: `${testMock.options.config.oAuthServerUrl}/${testMock.options.config.tenantId}/oauth2/v2.0/token`,
}),
testMock.usageCollector
);
});
});

it('should call MS API to generate new token when the cached token is expired', async () => {
const { connectorId, token, tokenType } =
microsoftDefenderEndpointConnectorMocks.createConnectorToken();
await connectorTokenManagerClientMock.create({
connectorId,
token,
expiresAtMillis: '2024-01-16T13:02:43.494Z',
tokenType,
describe('#generateNew()', () => {
it('should call microsoft api to get new token', async () => {
await msOAuthManagerMock.generateNew(testMock.usageCollector);

expect(testMock.instanceMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: `${testMock.options.config.oAuthServerUrl}/${testMock.options.config.tenantId}/oauth2/v2.0/token`,
}),
testMock.usageCollector
);
});
await msOAuthManagerMock.get(testMock.usageCollector);

expect(connectorTokenManagerClientMock.get).toHaveBeenCalledWith({
connectorId: '1',
tokenType: 'access_token',
it('should use stored token if it is different since the last time it was read', async () => {
await msOAuthManagerMock.get(testMock.usageCollector);
const { connectorId, tokenType } =
microsoftDefenderEndpointConnectorMocks.createConnectorToken();
await connectorTokenManagerClientMock.create({
connectorId,
token: 'different-token-here',
expiresAtMillis: '2050-01-16T13:02:43.494Z',
tokenType,
});
connectorTokenManagerClientMock.get.mockClear();
testMock.instanceMock.request.mockClear();
await msOAuthManagerMock.generateNew(testMock.usageCollector);

expect(connectorTokenManagerClientMock.get).toHaveBeenCalledTimes(1);
expect(testMock.instanceMock.request).not.toHaveBeenCalled();
});
expect(testMock.instanceMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: `${testMock.options.config.oAuthServerUrl}/${testMock.options.config.tenantId}/oauth2/v2.0/token`,
}),
testMock.usageCollector
);
});
});
Loading

0 comments on commit d9857fd

Please sign in to comment.