feat(Microsoft Graph Security Node): Support authenticating with the generic Microsoft OAuth2 credential (#32529)

This commit is contained in:
Csaba Tuncsik 2026-06-22 12:54:43 +02:00 committed by GitHub
parent 7416a23d04
commit c2a006aab8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 430 additions and 64 deletions

View File

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

View File

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

View File

@ -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<Promise<any>> = [];
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);

View File

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

View File

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

View File

@ -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": []
}