diff --git a/.changeset/aws-wif-token-file.md b/.changeset/aws-wif-token-file.md new file mode 100644 index 0000000000..308e874e6a --- /dev/null +++ b/.changeset/aws-wif-token-file.md @@ -0,0 +1,14 @@ +--- +'@backstage/integration-aws-node': minor +--- + +Added `webIdentityTokenFile` to `AwsIntegrationAccountConfig` and +`AwsIntegrationDefaultAccountConfig`. When set along with a `roleName`, +`DefaultAwsCredentialsManager` retrieves credentials by calling +`AssumeRoleWithWebIdentity` (via `fromTokenFile`) using the file's +contents as the web identity token. The file is re-read on each +credential refresh. + +The validator rejects combining `webIdentityTokenFile` with +`accessKeyId`/`secretAccessKey`, `profile`, or `externalId`, and +rejects setting it without a `roleName`. diff --git a/packages/integration-aws-node/README.md b/packages/integration-aws-node/README.md index 62df26756f..9329ef4f15 100644 --- a/packages/integration-aws-node/README.md +++ b/packages/integration-aws-node/README.md @@ -63,8 +63,18 @@ aws: - accountId: '444444444444' profile: my-profile-name - # Credentials can come from the AWS SDK's default creds chain + # Credentials can come from AssumeRoleWithWebIdentity using a token + # file rotated by an external process (the same primitive EKS IRSA + # uses, where the kubelet rotates a projected service account token). + # Useful for backends running outside AWS that need to assume roles + # in multiple accounts via OIDC. Cannot be combined with a profile, + # static credentials, or an externalId. - accountId: '555555555555' + roleName: 'my-iam-role-name' + webIdentityTokenFile: '/var/run/secrets/aws/token' + + # Credentials can come from the AWS SDK's default creds chain + - accountId: '666666666666' # Credentials for accounts can fall back to a common role name. # This is useful for account discovery use cases where the account diff --git a/packages/integration-aws-node/config.d.ts b/packages/integration-aws-node/config.d.ts index 3c5600efc3..a902bc0e5e 100644 --- a/packages/integration-aws-node/config.d.ts +++ b/packages/integration-aws-node/config.d.ts @@ -41,6 +41,11 @@ export interface Config { * @visibility secret */ externalId?: string; + + /** + * Path to a file on disk containing an OIDC web-identity token. + */ + webIdentityTokenFile?: string; }; /** @@ -118,6 +123,11 @@ export interface Config { * @visibility secret */ externalId?: string; + + /** + * Path to a file on disk containing an OIDC web-identity token. + */ + webIdentityTokenFile?: string; }>; }; } diff --git a/packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts index f1ec0ba6dc..ea88df08fe 100644 --- a/packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts @@ -22,6 +22,7 @@ import { Config, ConfigReader } from '@backstage/config'; import { fromNodeProviderChain, fromTemporaryCredentials, + fromTokenFile, } from '@aws-sdk/credential-providers'; import { join } from 'node:path'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; @@ -38,6 +39,7 @@ jest.mock('@aws-sdk/credential-providers', () => { ...originalModule, fromNodeProviderChain: jest.fn(), fromTemporaryCredentials: jest.fn(), + fromTokenFile: jest.fn(), }; }); @@ -554,5 +556,76 @@ describe('DefaultAwsCredentialsManager', () => { expect(await stsMock.call(0).thisValue.config.region()).toEqual(region); }); + + it('uses fromTokenFile when webIdentityTokenFile is set per-account with a roleName', async () => { + const wifCreds = { + accessKeyId: 'WIF_KEY', + secretAccessKey: 'WIF_SECRET', + sessionToken: 'WIF_SESSION', + expiration: new Date('2026-06-01'), + }; + (fromTokenFile as jest.Mock).mockReturnValue(async () => wifCreds); + + const wifConfig = new ConfigReader({ + aws: { + accounts: [ + { + accountId: '111111111111', + roleName: 'PortalRole', + webIdentityTokenFile: '/var/run/aws/token', + region: 'eu-west-1', + }, + ], + }, + }); + const provider = DefaultAwsCredentialsManager.fromConfig(wifConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '111111111111', + }); + + expect(fromTokenFile).toHaveBeenCalledWith( + expect.objectContaining({ + webIdentityTokenFile: '/var/run/aws/token', + roleArn: 'arn:aws:iam::111111111111:role/PortalRole', + roleSessionName: 'backstage', + clientConfig: expect.objectContaining({ region: 'eu-west-1' }), + }), + ); + expect(fromTemporaryCredentials).not.toHaveBeenCalled(); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + expect(creds).toEqual(wifCreds); + + const awsCredentialProvider2 = await provider.getCredentialProvider({ + accountId: '111111111111', + }); + expect(awsCredentialProvider).toBe(awsCredentialProvider2); + }); + + it('inherits webIdentityTokenFile from accountDefaults for on-demand registrations', async () => { + const wifConfig = new ConfigReader({ + aws: { + accountDefaults: { + roleName: 'DefaultRole', + webIdentityTokenFile: '/var/run/aws/default-token', + }, + }, + }); + const provider = DefaultAwsCredentialsManager.fromConfig(wifConfig); + await provider.getCredentialProvider({ accountId: '999999999999' }); + + expect(fromTokenFile).toHaveBeenCalledWith( + expect.objectContaining({ + webIdentityTokenFile: '/var/run/aws/default-token', + roleArn: 'arn:aws:iam::999999999999:role/DefaultRole', + }), + ); + }); + + it('does not call fromTokenFile when webIdentityTokenFile is unset, even with roleName', async () => { + const provider = DefaultAwsCredentialsManager.fromConfig(config); + await provider.getCredentialProvider({ accountId: '111111111111' }); + expect(fromTokenFile).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts index a02fea1b1c..9b10885464 100644 --- a/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts @@ -30,6 +30,7 @@ import { fromIni, fromNodeProviderChain, fromTemporaryCredentials, + fromTokenFile, } from '@aws-sdk/credential-providers'; import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; import { parse } from '@aws-sdk/util-arn-parser'; @@ -93,11 +94,12 @@ function getDefaultCredentialsChain( * Constructs the credential provider needed by the AWS SDK from the given account config * * Order of precedence: - * 1. Assume role with static creds - * 2. Assume role with main account creds - * 3. Static creds - * 4. Profile creds - * 5. Default AWS SDK creds chain + * 1. Assume role with web identity token file (no static creds) + * 2. Assume role with static creds + * 3. Assume role with main account creds + * 4. Static creds + * 5. Profile creds + * 6. Default AWS SDK creds chain */ function getSdkCredentialProvider( config: AwsIntegrationAccountConfig, @@ -106,13 +108,42 @@ function getSdkCredentialProvider( if (config.roleName) { const region = config.region ?? 'us-east-1'; const partition = config.partition ?? 'aws'; + const roleArn = `arn:${partition}:iam::${config.accountId}:role/${config.roleName}`; + + if (config.webIdentityTokenFile) { + // Defensive: same combinations the parser rejects. + if (config.accessKeyId) { + throw new Error( + `AWS integration account ${config.accountId} has both a web identity token file and static credentials configured, but only one must be specified`, + ); + } + if (config.profile) { + throw new Error( + `AWS integration account ${config.accountId} has both a web identity token file and a profile configured, but only one must be specified`, + ); + } + if (config.externalId) { + throw new Error( + `AWS integration account ${config.accountId} has both a web identity token file and an external ID configured; AssumeRoleWithWebIdentity does not support external IDs.`, + ); + } + return fromTokenFile({ + webIdentityTokenFile: config.webIdentityTokenFile, + roleArn, + roleSessionName: 'backstage', + clientConfig: { + region, + customUserAgent: 'backstage-aws-credentials-manager', + }, + }); + } return fromTemporaryCredentials({ masterCredentials: config.accessKeyId ? getStaticCredentials(config.accessKeyId!, config.secretAccessKey!) : mainAccountCredProvider, params: { - RoleArn: `arn:${partition}:iam::${config.accountId}:role/${config.roleName}`, + RoleArn: roleArn, RoleSessionName: 'backstage', ExternalId: config.externalId, }, @@ -267,6 +298,7 @@ export class DefaultAwsCredentialsManager implements AwsCredentialsManager { partition: this.accountDefaults.partition, region: this.accountDefaults.region, externalId: this.accountDefaults.externalId, + webIdentityTokenFile: this.accountDefaults.webIdentityTokenFile, }; const sdkCredentialProvider = getSdkCredentialProvider( config, diff --git a/packages/integration-aws-node/src/config.test.ts b/packages/integration-aws-node/src/config.test.ts index f761a8ac14..5bf58a9b1e 100644 --- a/packages/integration-aws-node/src/config.test.ts +++ b/packages/integration-aws-node/src/config.test.ts @@ -332,4 +332,118 @@ describe('readAwsIntegrationConfig', () => { ), ).toThrow(/no role name/); }); + + it('reads webIdentityTokenFile on accounts and accountDefaults', () => { + const output = readAwsIntegrationConfig( + buildConfig({ + accounts: [ + { + accountId: '111111111111', + roleName: 'my-role', + webIdentityTokenFile: '/var/run/aws/token', + }, + ], + accountDefaults: { + roleName: 'default-role', + webIdentityTokenFile: '/var/run/aws/default-token', + }, + }), + ); + expect(output.accounts[0].webIdentityTokenFile).toBe('/var/run/aws/token'); + expect(output.accountDefaults.webIdentityTokenFile).toBe( + '/var/run/aws/default-token', + ); + }); + + it('rejects webIdentityTokenFile combined with static credentials', () => { + expect(() => + readAwsIntegrationConfig( + buildConfig({ + accounts: [ + { + accountId: '111111111111', + roleName: 'my-role', + accessKeyId: 'ABC', + secretAccessKey: 'DEF', + webIdentityTokenFile: '/var/run/aws/token', + }, + ], + }), + ), + ).toThrow(/web identity token file and static credentials/); + }); + + it('rejects webIdentityTokenFile combined with profile', () => { + expect(() => + readAwsIntegrationConfig( + buildConfig({ + accounts: [ + { + accountId: '111111111111', + webIdentityTokenFile: '/var/run/aws/token', + profile: 'my-profile', + }, + ], + }), + ), + ).toThrow(/web identity token file and a profile/); + }); + + it('rejects webIdentityTokenFile without a role name', () => { + expect(() => + readAwsIntegrationConfig( + buildConfig({ + accounts: [ + { + accountId: '111111111111', + webIdentityTokenFile: '/var/run/aws/token', + }, + ], + }), + ), + ).toThrow(/web identity token file configured, but no role name/); + }); + + it('rejects webIdentityTokenFile combined with externalId', () => { + expect(() => + readAwsIntegrationConfig( + buildConfig({ + accounts: [ + { + accountId: '111111111111', + roleName: 'my-role', + webIdentityTokenFile: '/var/run/aws/token', + externalId: 'ext-1', + }, + ], + }), + ), + ).toThrow(/AssumeRoleWithWebIdentity does not support external IDs/); + }); + + it('rejects accountDefaults webIdentityTokenFile without a role name', () => { + expect(() => + readAwsIntegrationConfig( + buildConfig({ + accountDefaults: { + webIdentityTokenFile: '/var/run/aws/token', + }, + }), + ), + ).toThrow(/web identity token file configured, but no role name/); + }); + + it('rejects accountDefaults webIdentityTokenFile combined with externalId', () => { + expect(() => + readAwsIntegrationConfig( + buildConfig({ + accountDefaults: { + roleName: 'my-role', + webIdentityTokenFile: '/var/run/aws/token', + externalId: 'ext-1', + }, + }), + ), + ).toThrow(/AssumeRoleWithWebIdentity does not support external IDs/); + }); }); diff --git a/packages/integration-aws-node/src/config.ts b/packages/integration-aws-node/src/config.ts index 5cf34e4dd3..9acc2d069c 100644 --- a/packages/integration-aws-node/src/config.ts +++ b/packages/integration-aws-node/src/config.ts @@ -62,6 +62,11 @@ export type AwsIntegrationAccountConfig = { * The unique identifier needed to assume the role to retrieve temporary AWS credentials */ externalId?: string; + + /** + * Path to a file on disk containing an OIDC web-identity token. + */ + webIdentityTokenFile?: string; }; /** @@ -117,6 +122,11 @@ export type AwsIntegrationDefaultAccountConfig = { * The unique identifier needed to assume the role to retrieve temporary AWS credentials */ externalId?: string; + + /** + * Path to a file on disk containing an OIDC web-identity token. + */ + webIdentityTokenFile?: string; }; /** @@ -158,6 +168,7 @@ function readAwsIntegrationAccountConfig( region: config.getOptionalString('region'), partition: config.getOptionalString('partition'), externalId: config.getOptionalString('externalId'), + webIdentityTokenFile: config.getOptionalString('webIdentityTokenFile'), }; // Validate that the account config has the right combination of attributes @@ -203,6 +214,30 @@ function readAwsIntegrationAccountConfig( ); } + if (accountConfig.webIdentityTokenFile && accountConfig.accessKeyId) { + throw new Error( + `AWS integration account ${accountConfig.accountId} has both a web identity token file and static credentials configured, but only one must be specified`, + ); + } + + if (accountConfig.webIdentityTokenFile && accountConfig.profile) { + throw new Error( + `AWS integration account ${accountConfig.accountId} has both a web identity token file and a profile configured, but only one must be specified`, + ); + } + + if (accountConfig.webIdentityTokenFile && !accountConfig.roleName) { + throw new Error( + `AWS integration account ${accountConfig.accountId} has a web identity token file configured, but no role name.`, + ); + } + + if (accountConfig.webIdentityTokenFile && accountConfig.externalId) { + throw new Error( + `AWS integration account ${accountConfig.accountId} has both a web identity token file and an external ID configured; AssumeRoleWithWebIdentity does not support external IDs.`, + ); + } + return accountConfig; } @@ -256,6 +291,7 @@ function readAwsIntegrationAccountDefaultsConfig( partition: config.getOptionalString('partition'), region: config.getOptionalString('region'), externalId: config.getOptionalString('externalId'), + webIdentityTokenFile: config.getOptionalString('webIdentityTokenFile'), }; // Validate that the account config has the right combination of attributes @@ -277,6 +313,24 @@ function readAwsIntegrationAccountDefaultsConfig( ); } + if ( + !defaultAccountConfig.roleName && + defaultAccountConfig.webIdentityTokenFile + ) { + throw new Error( + `AWS integration account default configuration has a web identity token file configured, but no role name.`, + ); + } + + if ( + defaultAccountConfig.webIdentityTokenFile && + defaultAccountConfig.externalId + ) { + throw new Error( + `AWS integration account default configuration has both a web identity token file and an external ID configured; AssumeRoleWithWebIdentity does not support external IDs.`, + ); + } + return defaultAccountConfig; }