mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 18:49:20 +02:00
fix(Microsoft Entra Node): Refresh expired OAuth2 tokens (#30943)
This commit is contained in:
parent
d7d2071bdd
commit
ab849d3fa8
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { ignoreHttpStatusErrorsConfig } from './common';
|
||||
import { handleErrorPostReceive, microsoftApiRequest } from '../GenericFunctions';
|
||||
|
||||
export const groupOperations: INodeProperties[] = [
|
||||
|
|
@ -29,7 +30,7 @@ export const groupOperations: INodeProperties[] = [
|
|||
description: 'Create a group',
|
||||
routing: {
|
||||
request: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
method: 'POST',
|
||||
url: '/groups',
|
||||
},
|
||||
|
|
@ -45,7 +46,7 @@ export const groupOperations: INodeProperties[] = [
|
|||
description: 'Delete a group',
|
||||
routing: {
|
||||
request: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
method: 'DELETE',
|
||||
url: '=/groups/{{ $parameter["group"] }}',
|
||||
},
|
||||
|
|
@ -69,7 +70,7 @@ export const groupOperations: INodeProperties[] = [
|
|||
description: 'Retrieve data for a specific group',
|
||||
routing: {
|
||||
request: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
method: 'GET',
|
||||
url: '=/groups/{{ $parameter["group"] }}',
|
||||
},
|
||||
|
|
@ -85,7 +86,7 @@ export const groupOperations: INodeProperties[] = [
|
|||
description: 'Retrieve a list of groups',
|
||||
routing: {
|
||||
request: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
method: 'GET',
|
||||
url: '/groups',
|
||||
},
|
||||
|
|
@ -109,7 +110,7 @@ export const groupOperations: INodeProperties[] = [
|
|||
description: 'Update a group',
|
||||
routing: {
|
||||
request: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
method: 'PATCH',
|
||||
url: '=/groups/{{ $parameter["group"] }}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ignoreHttpStatusErrorsConfig } from './common';
|
||||
import { handleErrorPostReceive, microsoftApiRequest } from '../GenericFunctions';
|
||||
|
||||
export const userOperations: INodeProperties[] = [
|
||||
|
|
@ -32,7 +33,7 @@ export const userOperations: INodeProperties[] = [
|
|||
request: {
|
||||
method: 'POST',
|
||||
url: '=/groups/{{ $parameter["group"] }}/members/$ref',
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
|
|
@ -56,7 +57,7 @@ export const userOperations: INodeProperties[] = [
|
|||
request: {
|
||||
method: 'POST',
|
||||
url: '/users',
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
},
|
||||
output: {
|
||||
postReceive: [handleErrorPostReceive],
|
||||
|
|
@ -72,7 +73,7 @@ export const userOperations: INodeProperties[] = [
|
|||
request: {
|
||||
method: 'DELETE',
|
||||
url: '=/users/{{ $parameter["user"] }}',
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
|
|
@ -96,7 +97,7 @@ export const userOperations: INodeProperties[] = [
|
|||
request: {
|
||||
method: 'GET',
|
||||
url: '=/users/{{ $parameter["user"] }}',
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
},
|
||||
output: {
|
||||
postReceive: [handleErrorPostReceive],
|
||||
|
|
@ -112,7 +113,7 @@ export const userOperations: INodeProperties[] = [
|
|||
request: {
|
||||
method: 'GET',
|
||||
url: '/users',
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
|
|
@ -137,7 +138,7 @@ export const userOperations: INodeProperties[] = [
|
|||
request: {
|
||||
method: 'DELETE',
|
||||
url: '=/groups/{{ $parameter["group"] }}/members/{{ $parameter["user"] }}/$ref',
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
|
|
@ -161,7 +162,7 @@ export const userOperations: INodeProperties[] = [
|
|||
request: {
|
||||
method: 'PATCH',
|
||||
url: '=/users/{{ $parameter["user"] }}',
|
||||
ignoreHttpStatusErrors: true,
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export const ignoreHttpStatusErrorsConfig = {
|
||||
ignore: true as const,
|
||||
// 401 responses must be passed to requestWithAuthentication so expired OAuth2 tokens can refresh.
|
||||
except: [401],
|
||||
};
|
||||
|
|
@ -1,9 +1,19 @@
|
|||
import { CredentialsHelper } from '@nodes-testing/credentials-helper';
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import type { ILoadOptionsFunctions, WorkflowTestData } from 'n8n-workflow';
|
||||
import { convertN8nRequestToAxios } from 'n8n-core/dist/execution-engine/node-execution-context/utils/request-helper-functions';
|
||||
import type {
|
||||
IExecuteSingleFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IN8nHttpFullResponse,
|
||||
WorkflowTestData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import nock from 'nock';
|
||||
|
||||
import { microsoftEntraApiResponse, microsoftEntraNodeResponse } from './mocks';
|
||||
import { MicrosoftEntra } from '../MicrosoftEntra.node';
|
||||
import { ignoreHttpStatusErrorsConfig } from '../descriptions/common';
|
||||
import { handleErrorPostReceive } from '../GenericFunctions';
|
||||
|
||||
describe('Microsoft Entra Node', () => {
|
||||
const testHarness = new NodeTestHarness();
|
||||
|
|
@ -100,6 +110,39 @@ describe('Microsoft Entra Node', () => {
|
|||
}
|
||||
});
|
||||
|
||||
describe('HTTP status handling', () => {
|
||||
it('handles non-authentication errors in the node error handler', async () => {
|
||||
const axiosRequest = convertN8nRequestToAxios({
|
||||
method: 'DELETE',
|
||||
url: 'https://graph.microsoft.com/v1.0/users/missing-user-id',
|
||||
ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig,
|
||||
});
|
||||
const response: IN8nHttpFullResponse = {
|
||||
statusCode: 404,
|
||||
headers: {},
|
||||
body: {
|
||||
error: {
|
||||
code: 'Request_ResourceNotFound',
|
||||
message: 'Resource could not be found.',
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Microsoft Entra ID' }),
|
||||
getNodeParameter: jest.fn((parameterName: string) => {
|
||||
if (parameterName === 'resource') return 'user';
|
||||
if (parameterName === 'operation') return 'delete';
|
||||
return '';
|
||||
}),
|
||||
} as unknown as IExecuteSingleFunctions;
|
||||
|
||||
expect(axiosRequest.validateStatus?.(response.statusCode)).toBe(true);
|
||||
await expect(handleErrorPostReceive.call(context, [], response)).rejects.toThrow(
|
||||
"The required user doesn't match any existing one",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Load options', () => {
|
||||
it('should load group properties', async () => {
|
||||
const mockContext = {
|
||||
|
|
@ -223,4 +266,170 @@ describe('Microsoft Entra Node', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token refresh', () => {
|
||||
const tokenRefreshUrl = 'https://login.microsoftonline.com';
|
||||
const credentials = {
|
||||
microsoftEntraOAuth2Api: {
|
||||
grantType: 'authorizationCode',
|
||||
authUrl: `${tokenRefreshUrl}/common/oauth2/v2.0/authorize`,
|
||||
accessTokenUrl: `${tokenRefreshUrl}/common/oauth2/v2.0/token`,
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
scope: 'openid offline_access User.ReadWrite.All',
|
||||
authQueryParameters: 'response_mode=query',
|
||||
authentication: 'body',
|
||||
graphApiBaseUrl: 'https://graph.microsoft.com',
|
||||
oauthTokenData: {
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3599,
|
||||
access_token: 'ACCESSTOKEN',
|
||||
refresh_token: 'REFRESHTOKEN',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let updateCredentialsSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(CredentialsHelper.prototype, 'getParentTypes').mockReturnValue(['oAuth2Api']);
|
||||
|
||||
updateCredentialsSpy = jest
|
||||
.spyOn(CredentialsHelper.prototype, 'updateCredentialsOauthTokenData')
|
||||
.mockResolvedValue();
|
||||
|
||||
nock('https://graph.microsoft.com')
|
||||
.get('/v1.0/users')
|
||||
.query(true)
|
||||
.matchHeader('Authorization', 'Bearer ACCESSTOKEN')
|
||||
.reply(401, {
|
||||
error: {
|
||||
code: 'InvalidAuthenticationToken',
|
||||
message: 'Lifetime validation failed, the token is expired.',
|
||||
},
|
||||
});
|
||||
|
||||
nock(tokenRefreshUrl).post('/common/oauth2/v2.0/token').reply(200, {
|
||||
token_type: 'Bearer',
|
||||
scope: 'openid offline_access User.ReadWrite.All',
|
||||
expires_in: 3599,
|
||||
access_token: 'NEWACCESSTOKEN',
|
||||
refresh_token: 'NEWREFRESHTOKEN',
|
||||
});
|
||||
|
||||
nock('https://graph.microsoft.com')
|
||||
.get('/v1.0/users')
|
||||
.query(true)
|
||||
.matchHeader('Authorization', 'Bearer NEWACCESSTOKEN')
|
||||
.reply(200, {
|
||||
value: [
|
||||
{
|
||||
id: 'user-1',
|
||||
createdDateTime: '2025-04-06T13:15:34Z',
|
||||
displayName: 'Test User',
|
||||
userPrincipalName: 'test.user@example.com',
|
||||
mail: 'test.user@example.com',
|
||||
mailNickname: 'test.user',
|
||||
securityIdentifier: 'S-1-1-0',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
testHarness.setupTest(
|
||||
{
|
||||
description: 'should refresh an expired token when getting users',
|
||||
input: {
|
||||
workflowData: {
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
id: '1307e408-a8a5-464e-b858-494953e2f43b',
|
||||
name: 'When clicking ‘Execute workflow’',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
resource: 'user',
|
||||
operation: 'getAll',
|
||||
returnAll: false,
|
||||
limit: 50,
|
||||
filter: '',
|
||||
output: 'simple',
|
||||
requestOptions: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.microsoftEntra',
|
||||
typeVersion: 1,
|
||||
position: [220, 0],
|
||||
id: '3429f7f2-dfca-4b72-8913-43a582e96e66',
|
||||
name: 'Microsoft Entra ID',
|
||||
credentials: {
|
||||
microsoftEntraOAuth2Api: {
|
||||
id: 'Hot2KwSMSoSmMVqd',
|
||||
name: 'Microsoft Entra ID (Azure Active Directory) account',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'When clicking ‘Execute workflow’': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Microsoft Entra ID',
|
||||
type: NodeConnectionTypes.Main,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
nodeData: {
|
||||
'Microsoft Entra ID': [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
id: 'user-1',
|
||||
createdDateTime: '2025-04-06T13:15:34Z',
|
||||
displayName: 'Test User',
|
||||
userPrincipalName: 'test.user@example.com',
|
||||
mail: 'test.user@example.com',
|
||||
mailNickname: 'test.user',
|
||||
securityIdentifier: 'S-1-1-0',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
credentials,
|
||||
customAssertions: () => {
|
||||
expect(updateCredentialsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateCredentialsSpy.mock.calls[0][1]).toBe('microsoftEntraOAuth2Api');
|
||||
expect(updateCredentialsSpy.mock.calls[0][2]).toMatchObject({
|
||||
oauthTokenData: expect.objectContaining({
|
||||
access_token: 'NEWACCESSTOKEN',
|
||||
refresh_token: 'NEWREFRESHTOKEN',
|
||||
}),
|
||||
});
|
||||
expect(nock.isDone()).toBe(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user