From 6cfad368d750ce4f4cfe41a4d359793387ce2f8c Mon Sep 17 00:00:00 2001 From: Justin Hart Date: Wed, 13 May 2026 05:30:07 -0700 Subject: [PATCH] feat(AWS Node): Add IRSA to AWS AssumeRole system credential strategies (#22316) Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> --- .../aws/system-credentials-utils.test.ts | 150 ++++++++++++++++++ .../common/aws/system-credentials-utils.ts | 87 +++++++++- 2 files changed, 233 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/credentials/common/aws/system-credentials-utils.test.ts b/packages/nodes-base/credentials/common/aws/system-credentials-utils.test.ts index 2ac9d5e3fd5..b1881956156 100644 --- a/packages/nodes-base/credentials/common/aws/system-credentials-utils.test.ts +++ b/packages/nodes-base/credentials/common/aws/system-credentials-utils.test.ts @@ -811,4 +811,154 @@ describe('system-credentials-utils', () => { expect(result).toBeNull(); }); }); + + describe('getRoleForServiceAccountCredentials', () => { + it('should return null when AWS_ROLE_ARN or AWS_WEB_IDENTITY_TOKEN_FILE is not available via envGetter', async () => { + mockEnvGetter.mockImplementation(() => undefined); + + const result = await credentialsResolver.roleForServiceAccount(); + expect(result).toBeNull(); + expect(mockEnvGetter).toHaveBeenCalledWith('AWS_ROLE_ARN'); + expect(mockEnvGetter).toHaveBeenCalledWith('AWS_WEB_IDENTITY_TOKEN_FILE'); + }); + + it('should fetch credentials successfully with role and token file from envGetter', async () => { + mockEnvGetter.mockImplementation((key: string) => { + switch (key) { + case 'AWS_ROLE_ARN': + return 'arn:aws:iam::123456789012:role/test-role'; + case 'AWS_WEB_IDENTITY_TOKEN_FILE': + return '/tmp/token'; + default: + return undefined; + } + }); + + mockReadFile.mockResolvedValue('test-web-identity-token'); + + const mockCredentials = { + AssumeRoleWithWebIdentityResponse: { + AssumeRoleWithWebIdentityResult: { + Credentials: { + AccessKeyId: 'test-access-key', + SecretAccessKey: 'test-secret-key', + SessionToken: 'test-token', + }, + }, + }, + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockCredentials), + }); + + const result = await credentialsResolver.roleForServiceAccount(); + expect(result).toEqual({ + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + sessionToken: 'test-token', + }); + + expect(mockReadFile).toHaveBeenCalledWith('/tmp/token', 'utf8'); + expect(global.fetch).toHaveBeenCalledWith( + 'https://sts.amazonaws.com', + expect.objectContaining({ + method: 'POST', + headers: { + 'User-Agent': 'n8n-aws-credential', + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: expect.stringContaining('Action=AssumeRoleWithWebIdentity'), + }), + ); + expect((global.fetch as jest.Mock).mock.calls[0][1].body).toContain( + 'RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Ftest-role', + ); + }); + + it('should return null when fetch fails', async () => { + mockEnvGetter.mockImplementation((key: string) => { + switch (key) { + case 'AWS_ROLE_ARN': + return 'arn:aws:iam::123456789012:role/test-role'; + case 'AWS_WEB_IDENTITY_TOKEN_FILE': + return '/tmp/token'; + default: + return undefined; + } + }); + mockReadFile.mockResolvedValue('test-web-identity-token'); + (global.fetch as jest.Mock).mockResolvedValue({ ok: false }); + + const result = await credentialsResolver.roleForServiceAccount(); + expect(result).toBeNull(); + }); + + it('should return null when credentials are incomplete', async () => { + mockEnvGetter.mockImplementation((key: string) => { + switch (key) { + case 'AWS_ROLE_ARN': + return 'arn:aws:iam::123456789012:role/test-role'; + case 'AWS_WEB_IDENTITY_TOKEN_FILE': + return '/tmp/token'; + default: + return undefined; + } + }); + mockReadFile.mockResolvedValue('test-web-identity-token'); + const incomplete = { + AssumeRoleWithWebIdentityResponse: { + AssumeRoleWithWebIdentityResult: { + Credentials: { + AccessKeyId: 'test-access-key', + }, + }, + }, + }; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(incomplete), + }); + + const result = await credentialsResolver.roleForServiceAccount(); + expect(result).toBeNull(); + }); + + it('should return null when fetch throws an error', async () => { + mockEnvGetter.mockImplementation((key: string) => { + switch (key) { + case 'AWS_ROLE_ARN': + return 'arn:aws:iam::123456789012:role/test-role'; + case 'AWS_WEB_IDENTITY_TOKEN_FILE': + return '/tmp/token'; + default: + return undefined; + } + }); + mockReadFile.mockResolvedValue('test-web-identity-token'); + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + const result = await credentialsResolver.roleForServiceAccount(); + expect(result).toBeNull(); + }); + + it('should return null when token file is empty', async () => { + mockEnvGetter.mockImplementation((key: string) => { + switch (key) { + case 'AWS_ROLE_ARN': + return 'arn:aws:iam::123456789012:role/test-role'; + case 'AWS_WEB_IDENTITY_TOKEN_FILE': + return '/tmp/token'; + default: + return undefined; + } + }); + mockReadFile.mockResolvedValue(''); + + const result = await credentialsResolver.roleForServiceAccount(); + expect(result).toBeNull(); + }); + }); }); diff --git a/packages/nodes-base/credentials/common/aws/system-credentials-utils.ts b/packages/nodes-base/credentials/common/aws/system-credentials-utils.ts index c821b88b0e3..cefbe427257 100644 --- a/packages/nodes-base/credentials/common/aws/system-credentials-utils.ts +++ b/packages/nodes-base/credentials/common/aws/system-credentials-utils.ts @@ -3,7 +3,12 @@ import { Container } from '@n8n/di'; import { ApplicationError } from 'n8n-workflow'; import { readFile } from 'fs/promises'; -type Resolvers = 'environment' | 'podIdentity' | 'containerMetadata' | 'instanceMetadata'; +type Resolvers = + | 'environment' + | 'roleForServiceAccount' + | 'podIdentity' + | 'containerMetadata' + | 'instanceMetadata'; type ReturnData = { accessKeyId: string; secretAccessKey: string; @@ -17,15 +22,17 @@ export const credentialsResolver: Record Promise = { + 'User-Agent': 'n8n-aws-credential', + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }; + + const body = new URLSearchParams({ + Action: 'AssumeRoleWithWebIdentity', + RoleArn: iamRole, + RoleSessionName: 'n8n-web-identity-session', + WebIdentityToken: token, + Version: '2011-06-15', + }); + + // Global STS endpoint; China/GovCloud regions unsupported until region is passed through getSystemCredentials + // STS supports Accept: application/json (undocumented) to return JSON instead of XML. + const credentialsResponse = await fetch('https://sts.amazonaws.com', { + method: 'POST', + headers, + body: body.toString(), + signal: AbortSignal.timeout(2000), + }); + + if (!credentialsResponse.ok) { + return null; + } + + const data = await credentialsResponse.json(); + const credentialsData = + data?.AssumeRoleWithWebIdentityResponse?.AssumeRoleWithWebIdentityResult?.Credentials; + + if (!credentialsData || !credentialsData.AccessKeyId || !credentialsData.SecretAccessKey) { + return null; + } + + return { + accessKeyId: credentialsData.AccessKeyId, + secretAccessKey: credentialsData.SecretAccessKey, + sessionToken: credentialsData.SessionToken, + }; + } catch (error) { + return null; + } +}