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:
@@ -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`.
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user