feat(integration-aws-node): add per-account webIdentityTokenFile config

Adds an optional `webIdentityTokenFile` field to
`AwsIntegrationAccountConfig` and `AwsIntegrationDefaultAccountConfig`.
When set on a per-account config along with a `roleName` and no static
credentials, `DefaultAwsCredentialsManager` now retrieves credentials
by calling `AssumeRoleWithWebIdentity` directly using the file's
contents as the web identity token (via `fromTokenFile`). The token
file is re-read on each refresh, so an external process can rotate it
in place — the same mechanism EKS IRSA uses, where the kubelet rotates
a projected service account token at the path identified by
`AWS_WEB_IDENTITY_TOKEN_FILE`.

This unlocks multi-account `AssumeRoleWithWebIdentity` for backends
running outside AWS (GKE, Cloud Run, Vault sidecars, etc.) without
requiring every plugin to construct a custom `AwsCredentialsManager`.
Existing call sites and configurations are unaffected — the new path
is opt-in via the new optional field.

Validator rejects:

- `webIdentityTokenFile` combined with static credentials
  (`accessKeyId`/`secretAccessKey`) on the same account
- `webIdentityTokenFile` combined with `profile` on the same account
- `webIdentityTokenFile` without a `roleName` (matches the existing
  precedent for `externalId`/`region`/`partition` without `roleName`)
- `webIdentityTokenFile` combined with `externalId` (the STS
  `AssumeRoleWithWebIdentity` API does not accept an external ID)

Same rules apply at the `accountDefaults` level. The `!config.accessKeyId`
guard in `getSdkCredentialProvider` is defensive — it protects callers
that build an `AwsIntegrationAccountConfig` directly without going
through `readAwsIntegrationConfig`. In that case we fall through to the
existing static-creds AssumeRole path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Brian Hudson <brian.r.hudson@gmail.com>
This commit is contained in:
Brian Hudson
2026-05-06 16:46:25 -04:00
parent fe697792c5
commit 8df06ec2bc
7 changed files with 314 additions and 7 deletions
+14
View File
@@ -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`.
+11 -1
View File
@@ -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
+10
View File
@@ -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;
}>;
};
}
@@ -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();
});
});
});
@@ -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,
@@ -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/);
});
});
@@ -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;
}