From c2a006aab899239b4b10495520814119a8a0fca3 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Mon, 22 Jun 2026 12:54:43 +0200 Subject: [PATCH] feat(Microsoft Graph Security Node): Support authenticating with the generic Microsoft OAuth2 credential (#32529) --- .../GraphSecurity/GenericFunctions.ts | 28 +- .../MicrosoftGraphSecurity.node.ts | 33 +++ .../test/GenericFunctions.test.ts | 280 ++++++++++++++---- .../test/MicrosoftGraphSecurity.node.test.ts | 23 ++ .../secureScore.get.genericOAuth2.test.ts | 47 +++ ...ecureScore.get.genericOAuth2.workflow.json | 83 ++++++ 6 files changed, 430 insertions(+), 64 deletions(-) create mode 100644 packages/nodes-base/nodes/Microsoft/GraphSecurity/test/MicrosoftGraphSecurity.node.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.workflow.json diff --git a/packages/nodes-base/nodes/Microsoft/GraphSecurity/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/GraphSecurity/GenericFunctions.ts index f87d3332ecb..ed59a3f9235 100644 --- a/packages/nodes-base/nodes/Microsoft/GraphSecurity/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/GraphSecurity/GenericFunctions.ts @@ -7,6 +7,23 @@ import type { } from 'n8n-workflow'; import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +export type GraphSecurityCredentialType = 'microsoftGraphSecurityOAuth2Api' | 'microsoftOAuth2Api'; + +/** + * Resolves which credential type the node is configured to use. Defaults to the + * node-specific `microsoftGraphSecurityOAuth2Api` so existing workflows (and + * nodes without an explicit `authentication` selection) keep working unchanged, + * while allowing the generic `microsoftOAuth2Api` (Graph) credential to be + * selected. + */ +export function getGraphSecurityCredentialType( + this: IExecuteFunctions, +): GraphSecurityCredentialType { + return this.getNodeParameter('authentication', 0) === 'microsoftOAuth2Api' + ? 'microsoftOAuth2Api' + : 'microsoftGraphSecurityOAuth2Api'; +} + export async function msGraphSecurityApiRequest( this: IExecuteFunctions, method: IHttpRequestMethods, @@ -15,17 +32,26 @@ export async function msGraphSecurityApiRequest( qs: IDataObject = {}, headers: IDataObject = {}, ) { + const credentialType = getGraphSecurityCredentialType.call(this); + const credentials = await this.getCredentials<{ oauthTokenData: { access_token: string; }; graphApiBaseUrl?: string; - }>('microsoftGraphSecurityOAuth2Api'); + }>(credentialType); const { oauthTokenData: { access_token }, } = credentials; + if (!access_token) { + throw new NodeOperationError( + this.getNode(), + 'No access token found in the credential. Reconnect the OAuth2 credential.', + ); + } + const baseUrl = ( typeof credentials.graphApiBaseUrl === 'string' && credentials.graphApiBaseUrl !== '' ? credentials.graphApiBaseUrl diff --git a/packages/nodes-base/nodes/Microsoft/GraphSecurity/MicrosoftGraphSecurity.node.ts b/packages/nodes-base/nodes/Microsoft/GraphSecurity/MicrosoftGraphSecurity.node.ts index e5641f3eb8b..54b981aeb38 100644 --- a/packages/nodes-base/nodes/Microsoft/GraphSecurity/MicrosoftGraphSecurity.node.ts +++ b/packages/nodes-base/nodes/Microsoft/GraphSecurity/MicrosoftGraphSecurity.node.ts @@ -38,9 +38,42 @@ export class MicrosoftGraphSecurity implements INodeType { { name: 'microsoftGraphSecurityOAuth2Api', required: true, + displayOptions: { + show: { + authentication: ['microsoftGraphSecurityOAuth2Api'], + }, + }, + }, + { + name: 'microsoftOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['microsoftOAuth2Api'], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Graph Security OAuth2', + value: 'microsoftGraphSecurityOAuth2Api', + }, + { + name: 'Microsoft OAuth2 (Graph)', + value: 'microsoftOAuth2Api', + description: + 'Generic Microsoft Graph credential. It must have the SecurityEvents.ReadWrite.All offline_access scope with Entra admin consent.', + }, + ], + default: 'microsoftGraphSecurityOAuth2Api', + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/GenericFunctions.test.ts index fd224a1f5c5..07dec0079bd 100644 --- a/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/GenericFunctions.test.ts @@ -29,6 +29,13 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { }; mockExecuteFunctions.getNode.mockReturnValue(mockNode); vi.clearAllMocks(); + + // Register this AFTER clearAllMocks so it survives. Unstubbed getNodeParameter returns its + // own 3rd-arg default (or undefined) instead of a truthy deep-mock proxy, so a node with no + // explicit authentication resolves to the legacy credential for the right reason: the + // credential-type resolver only switches to the generic credential on an exact + // 'microsoftOAuth2Api' match, everything else (incl. undefined) falls back to legacy. + mockExecuteFunctions.getNodeParameter.mockImplementation((_name, _i, fallback) => fallback); }); afterEach(() => { @@ -193,31 +200,35 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { }); describe('credential handling', () => { - it('should handle missing credentials', async () => { + it('propagates credential-resolution failures without issuing a request', async () => { mockExecuteFunctions.getCredentials.mockRejectedValue(new Error('Credentials not found')); await expect( msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'), ).rejects.toThrow('Credentials not found'); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'microsoftGraphSecurityOAuth2Api', + ); + expect(mockRequest).not.toHaveBeenCalled(); }); - it('should handle malformed credentials', async () => { + it('throws a NodeOperationError when the credential has no access token', async () => { mockExecuteFunctions.getCredentials.mockResolvedValue({ oauthTokenData: {}, } as any); mockRequest.mockResolvedValue({ data: 'test' }); - const result = await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + await expect( + msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'), + ).rejects.toThrow(NodeOperationError); - expect(mockRequest).toHaveBeenCalledWith({ - headers: { - Authorization: 'Bearer undefined', - }, - method: 'GET', - uri: 'https://graph.microsoft.com/v1.0/security/alerts', - json: true, - }); - expect(result).toEqual({ data: 'test' }); + await expect( + msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'), + ).rejects.toThrow('No access token found in the credential'); + + expect(mockRequest).not.toHaveBeenCalled(); }); }); @@ -264,11 +275,12 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { }; mockRequest.mockRejectedValue(apiError); - await expect( - msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'), - ).rejects.toThrow(NodeApiError); + const thrown: unknown = await msGraphSecurityApiRequest + .call(mockExecuteFunctions, 'GET', '/alerts') + .catch((error: unknown) => error); - expect(apiError.error.error.message).toBe('Request failed with bad request'); + expect(thrown).toBeInstanceOf(NodeApiError); + expect((thrown as NodeApiError).message).toBe('Request failed with bad request'); }); it('should handle Http request failed errors with JSON content', async () => { @@ -297,11 +309,12 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { }; mockRequest.mockRejectedValue(apiError); - await expect( - msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'), - ).rejects.toThrow(NodeApiError); + const thrown: unknown = await msGraphSecurityApiRequest + .call(mockExecuteFunctions, 'GET', '/alerts') + .catch((error: unknown) => error); - expect(apiError.error.error.message).toContain( + expect(thrown).toBeInstanceOf(NodeApiError); + expect((thrown as NodeApiError).message).toContain( 'Please check that your query parameter syntax is correct', ); }); @@ -316,11 +329,12 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { }; mockRequest.mockRejectedValue(apiError); - await expect( - msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'), - ).rejects.toThrow(NodeApiError); + const thrown: unknown = await msGraphSecurityApiRequest + .call(mockExecuteFunctions, 'GET', '/alerts') + .catch((error: unknown) => error); - expect(apiError.error.error.message).toContain( + expect(thrown).toBeInstanceOf(NodeApiError); + expect((thrown as NodeApiError).message).toContain( 'Please check that your query parameter syntax is correct', ); }); @@ -557,6 +571,181 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { json: true, }); }); + + describe('generic OAuth2 branch', () => { + beforeEach(() => { + mockExecuteFunctions.getNodeParameter.mockImplementation((name, _i, fallback) => + name === 'authentication' ? 'microsoftOAuth2Api' : fallback, + ); + }); + + it('should use US Government cloud endpoint', async () => { + const mockResponse = { data: 'test' }; + mockRequest.mockResolvedValue(mockResponse); + mockExecuteFunctions.getCredentials.mockResolvedValue({ + oauthTokenData: { + access_token: 'test-access-token', + }, + graphApiBaseUrl: 'https://graph.microsoft.us', + }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockRequest).toHaveBeenCalledWith({ + headers: { + Authorization: 'Bearer test-access-token', + }, + method: 'GET', + uri: 'https://graph.microsoft.us/v1.0/security/alerts', + json: true, + }); + }); + + it('should use US Government DOD cloud endpoint', async () => { + const mockResponse = { data: 'test' }; + mockRequest.mockResolvedValue(mockResponse); + mockExecuteFunctions.getCredentials.mockResolvedValue({ + oauthTokenData: { + access_token: 'test-access-token', + }, + graphApiBaseUrl: 'https://dod-graph.microsoft.us', + }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockRequest).toHaveBeenCalledWith({ + headers: { + Authorization: 'Bearer test-access-token', + }, + method: 'GET', + uri: 'https://dod-graph.microsoft.us/v1.0/security/alerts', + json: true, + }); + }); + + it('should use China cloud endpoint', async () => { + const mockResponse = { data: 'test' }; + mockRequest.mockResolvedValue(mockResponse); + mockExecuteFunctions.getCredentials.mockResolvedValue({ + oauthTokenData: { + access_token: 'test-access-token', + }, + graphApiBaseUrl: 'https://microsoftgraph.chinacloudapi.cn', + }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockRequest).toHaveBeenCalledWith({ + headers: { + Authorization: 'Bearer test-access-token', + }, + method: 'GET', + uri: 'https://microsoftgraph.chinacloudapi.cn/v1.0/security/alerts', + json: true, + }); + }); + }); + }); + + describe('credential resolution', () => { + it('should use the legacy credential by default (backfill/fallback contract)', async () => { + mockRequest.mockResolvedValue({ data: 'test' }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'microsoftGraphSecurityOAuth2Api', + ); + }); + + it('should use the legacy credential when authentication is microsoftGraphSecurityOAuth2Api', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((name, _i, fallback) => + name === 'authentication' ? 'microsoftGraphSecurityOAuth2Api' : fallback, + ); + mockRequest.mockResolvedValue({ data: 'test' }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'microsoftGraphSecurityOAuth2Api', + ); + }); + + it('should use the generic credential when authentication is microsoftOAuth2Api', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((name, _i, fallback) => + name === 'authentication' ? 'microsoftOAuth2Api' : fallback, + ); + mockRequest.mockResolvedValue({ data: 'test' }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api'); + }); + + it('resolves the legacy credential for a saved node with no authentication param', async () => { + mockRequest.mockResolvedValue({ data: 'test' }); + + await expect( + msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'), + ).resolves.not.toThrow(); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'microsoftGraphSecurityOAuth2Api', + ); + }); + + it('resolves the legacy credential for any unrecognized authentication value', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((name, _i, fallback) => + name === 'authentication' ? 'somethingElse' : fallback, + ); + mockRequest.mockResolvedValue({ data: 'test' }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'microsoftGraphSecurityOAuth2Api', + ); + }); + + it('resolves the legacy credential when the resolved authentication value is undefined', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((name, _i, fallback) => + name === 'authentication' ? undefined : fallback, + ); + mockRequest.mockResolvedValue({ data: 'test' }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith( + 'microsoftGraphSecurityOAuth2Api', + ); + }); + + it('should build a default-base-URL request on the generic branch', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((name, _i, fallback) => + name === 'authentication' ? 'microsoftOAuth2Api' : fallback, + ); + mockExecuteFunctions.getCredentials.mockResolvedValue({ + oauthTokenData: { + access_token: 'test-access-token', + }, + }); + mockRequest.mockResolvedValue({ data: 'test' }); + + await msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'); + + expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api'); + expect(mockRequest).toHaveBeenCalledWith({ + headers: { + Authorization: 'Bearer test-access-token', + }, + method: 'GET', + uri: 'https://graph.microsoft.com/v1.0/security/alerts', + json: true, + }); + }); }); }); @@ -666,11 +855,11 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { }); it('should use the correct node context', () => { - try { + expect(() => { throwOnEmptyUpdate.call(mockExecuteFunctions); - } catch (error) { - expect(mockExecuteFunctions.getNode).toHaveBeenCalled(); - } + }).toThrow(); + + expect(mockExecuteFunctions.getNode).toHaveBeenCalled(); }); it('should always throw regardless of input', () => { @@ -689,41 +878,6 @@ describe('Microsoft GraphSecurity GenericFunctions', () => { }); }); - it('should handle concurrent requests', async () => { - const mockResponse = { data: 'test' }; - mockRequest.mockResolvedValue(mockResponse); - - const promises: Array> = []; - for (let i = 0; i < 5; i++) { - promises.push(msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts/' + i)); - } - - const results = await Promise.all(promises); - - expect(results).toHaveLength(5); - expect(mockRequest).toHaveBeenCalledTimes(5); - }); - - it('should handle extremely large request bodies', async () => { - const mockResponse = { success: true }; - const largeBody = { data: 'x'.repeat(10000) }; - mockRequest.mockResolvedValue(mockResponse); - - const result = await msGraphSecurityApiRequest.call( - mockExecuteFunctions, - 'POST', - '/alerts', - largeBody, - ); - - expect(result).toEqual(mockResponse); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - body: largeBody, - }), - ); - }); - it('should handle empty parameters gracefully', async () => { const mockResponse = { data: 'test' }; mockRequest.mockResolvedValue(mockResponse); diff --git a/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/MicrosoftGraphSecurity.node.test.ts b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/MicrosoftGraphSecurity.node.test.ts new file mode 100644 index 00000000000..95226f56bb7 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/MicrosoftGraphSecurity.node.test.ts @@ -0,0 +1,23 @@ +import { MicrosoftGraphSecurity } from '../MicrosoftGraphSecurity.node'; + +describe('MicrosoftGraphSecurity node description', () => { + const node = new MicrosoftGraphSecurity(); + + const authProp = node.description.properties.find((p) => p.name === 'authentication'); + const credentials = node.description.credentials ?? []; + const legacyCred = credentials.find((c) => c.name === 'microsoftGraphSecurityOAuth2Api'); + const genericCred = credentials.find((c) => c.name === 'microsoftOAuth2Api'); + + it('defaults authentication to the legacy gate value', () => { + expect(authProp?.default).toBe('microsoftGraphSecurityOAuth2Api'); + }); + + it('gates the legacy credential on exactly the authentication default', () => { + // Reference the property default so the credential gate and the default cannot drift. + expect(legacyCred?.displayOptions?.show?.authentication).toContain(authProp?.default); + }); + + it('gates the generic credential on microsoftOAuth2Api', () => { + expect(genericCred?.displayOptions?.show?.authentication).toContain('microsoftOAuth2Api'); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.test.ts b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.test.ts new file mode 100644 index 00000000000..5fe97e3a9eb --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.test.ts @@ -0,0 +1,47 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +describe('Test MicrosoftGraphSecurity, secureScore => get (generic OAuth2)', () => { + const credentials = { + microsoftOAuth2Api: { + oauthTokenData: { + access_token: 'test-access-token', + }, + }, + }; + + beforeAll(() => { + nock('https://graph.microsoft.com') + .get('/v1.0/security/secureScores/test-secure-score-id') + .matchHeader('Authorization', 'Bearer test-access-token') + .reply(200, { + '@odata.context': + 'https://graph.microsoft.com/v1.0/$metadata#security/secureScores/$entity', + id: 'test-secure-score-id', + azureTenantId: 'tenant-123', + activeUserCount: 100, + createdDateTime: '2023-01-01T00:00:00Z', + currentScore: 85, + maxScore: 100, + averageComparativeScores: [ + { + basis: 'AllTenants', + averageScore: 75.5, + }, + ], + controlScores: [ + { + controlName: 'Enable MFA', + controlCategory: 'Identity', + score: 10, + maxScore: 10, + }, + ], + }); + }); + + new NodeTestHarness().setupTests({ + credentials, + workflowFiles: ['secureScore.get.genericOAuth2.workflow.json'], + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.workflow.json b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.workflow.json new file mode 100644 index 00000000000..b3e3082b9e1 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/GraphSecurity/test/workflow/secureScore.get.genericOAuth2.workflow.json @@ -0,0 +1,83 @@ +{ + "name": "Microsoft GraphSecurity SecureScore Get Generic OAuth2 Test", + "nodes": [ + { + "parameters": {}, + "id": "trigger-id", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [820, 360] + }, + { + "parameters": { + "authentication": "microsoftOAuth2Api", + "resource": "secureScore", + "operation": "get", + "secureScoreId": "test-secure-score-id" + }, + "id": "node-id", + "name": "Microsoft Graph Security", + "type": "n8n-nodes-base.microsoftGraphSecurity", + "typeVersion": 1, + "position": [1040, 360], + "credentials": { + "microsoftOAuth2Api": { + "id": "credential-id", + "name": "Microsoft OAuth2" + } + } + } + ], + "pinData": { + "Microsoft Graph Security": [ + { + "json": { + "id": "test-secure-score-id", + "azureTenantId": "tenant-123", + "activeUserCount": 100, + "createdDateTime": "2023-01-01T00:00:00Z", + "currentScore": 85, + "maxScore": 100, + "averageComparativeScores": [ + { + "basis": "AllTenants", + "averageScore": 75.5 + } + ], + "controlScores": [ + { + "controlName": "Enable MFA", + "controlCategory": "Identity", + "score": 10, + "maxScore": 10 + } + ] + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Microsoft Graph Security", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "test-version-id", + "id": "test-workflow-id", + "meta": { + "instanceId": "test-instance-id" + }, + "tags": [] +}