fix(Microsoft Entra Node): Refresh expired OAuth2 tokens (#30943)

This commit is contained in:
Michael Kret 2026-06-04 09:29:56 +03:00 committed by GitHub
parent d7d2071bdd
commit ab849d3fa8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 229 additions and 13 deletions

View File

@ -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"] }}',
},

View File

@ -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: [

View File

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

View File

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