feat(AWS Node): Add IRSA to AWS AssumeRole system credential strategies (#22316)

Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
Justin Hart 2026-05-13 05:30:07 -07:00 committed by GitHub
parent ad6c470f8f
commit 6cfad368d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 233 additions and 4 deletions

View File

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

View File

@ -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<Resolvers, () => Promise<ReturnData | n
instanceMetadata: getInstanceMetadataCredentials,
containerMetadata: getContainerMetadataCredentials,
podIdentity: getPodIdentityCredentials,
roleForServiceAccount: getRoleForServiceAccountCredentials,
};
/**
* Retrieves AWS credentials from various system sources following the AWS credential chain.
* Attempts to get credentials in the following order:
* 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN)
* 2. EKS Pod Identity (AWS_CONTAINER_CREDENTIALS_FULL_URI)
* 3. ECS/Fargate container metadata (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)
* 4. EC2 instance metadata service
* 2. IAM Role for Service Account (AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE)
* 3. EKS Pod Identity (AWS_CONTAINER_CREDENTIALS_FULL_URI)
* 4. ECS/Fargate container metadata (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)
* 5. EC2 instance metadata service
*/
export async function getSystemCredentials() {
if (!Container.get(SecurityConfig).awsSystemCredentialsAccess) {
@ -36,6 +43,7 @@ export async function getSystemCredentials() {
const resolveOrder: Resolvers[] = [
'environment',
'roleForServiceAccount',
'podIdentity',
'containerMetadata',
'instanceMetadata',
@ -267,3 +275,74 @@ async function getPodIdentityCredentials() {
return null;
}
}
/**
* Retrieves AWS credentials by assuming a role via OIDC web identity (IRSA).
* Used when running in EKS with IAM Roles for Service Accounts configured.
* Reads the OIDC token from the file at AWS_WEB_IDENTITY_TOKEN_FILE and calls
* STS AssumeRoleWithWebIdentity to exchange it for temporary credentials.
*
* @returns Promise resolving to credentials object or null if IRSA is not configured
*
* @see {@link https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html IRSA}
* @see {@link https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html STS API}
* @see {@link https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-web-identity/src/fromWebToken.ts AWS SDK v3 implementation}
*/
async function getRoleForServiceAccountCredentials() {
const iamRole = envGetter('AWS_ROLE_ARN');
const webIdentityTokenFile = envGetter('AWS_WEB_IDENTITY_TOKEN_FILE');
try {
if (!iamRole || !webIdentityTokenFile) {
return null;
}
const token = (await readFile(webIdentityTokenFile, 'utf8')).trim();
if (!token) {
return null;
}
const headers: Record<string, string> = {
'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;
}
}