Skip to content

Commit 058a0bf

Browse files
authored
fix(cli): short-lived credentials are not refreshed (#32354)
The CLI immediately invokes credential *providers* to produce *credentials*, and passes the credentials around instead of the providers. This means that if short-lived credentials expire (like session credentials from roles), there is no way to refresh them. CLI calls will start to fail if that happens. To fix this, instead of resolving providers to credentials, pass providers around instead. Implications for auth plugins ------------- This widens the plugin protocol: the new plugin protocol *forced* a translation to V3 credentials, and had no way to return V3 providers. While it is now possible to return V3 Credential Providers from the plugin protocol, plugin writers cannot easily take advantage of that protocol because there have been ~8 CLI releases that only support V3 credentials and will fail at runtime of V3 providers are returned. To support this, pass a new options argument into `getProvider()`: this will indicate whether V3 Providers are supported or not. Plugins can return a provider if the CLI indicates that it supports V3 providers, and avoid doing that if the CLI indicates it won't. That way, plugins can be rewritten to take advantage of returning V3 providers without crashing on CLI versions `2.167.0..(this releases)`. This also affects #32111 in which the plugin contract is being moved. Closes #32287. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent bf77e51 commit 058a0bf

19 files changed

+462
-109
lines changed

packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { loadSharedConfigFiles } from '@smithy/shared-ini-file-loader';
55
import { AwsCredentialIdentityProvider, Logger } from '@smithy/types';
66
import * as promptly from 'promptly';
77
import { ProxyAgent } from 'proxy-agent';
8+
import { makeCachingProvider } from './provider-caching';
89
import type { SdkHttpOptions } from './sdk-provider';
910
import { readIfPossible } from './util';
1011
import { debug } from '../../logging';
@@ -23,6 +24,8 @@ const DEFAULT_TIMEOUT = 300000;
2324
export class AwsCliCompatible {
2425
/**
2526
* Build an AWS CLI-compatible credential chain provider
27+
*
28+
* The credential chain returned by this function is always caching.
2629
*/
2730
public static async credentialChainBuilder(
2831
options: CredentialChainOptions = {},
@@ -43,13 +46,13 @@ export class AwsCliCompatible {
4346
* environment credentials still take precedence over AWS_PROFILE
4447
*/
4548
if (options.profile) {
46-
return fromIni({
49+
return makeCachingProvider(fromIni({
4750
profile: options.profile,
4851
ignoreCache: true,
4952
mfaCodeProvider: tokenCodeFn,
5053
clientConfig,
5154
logger: options.logger,
52-
});
55+
}));
5356
}
5457

5558
const envProfile = process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE;
@@ -74,6 +77,8 @@ export class AwsCliCompatible {
7477
* fromContainerMetadata()
7578
* fromTokenFile()
7679
* fromInstanceMetadata()
80+
*
81+
* The NodeProviderChain is already cached.
7782
*/
7883
const nodeProviderChain = fromNodeProviderChain({
7984
profile: envProfile,

packages/aws-cdk/lib/api/aws-auth/cached.ts

+10
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,13 @@ export function cached<A extends object, B>(obj: A, sym: symbol, fn: () => B): B
1010
}
1111
return (obj as any)[sym];
1212
}
13+
14+
/**
15+
* Like 'cached', but async
16+
*/
17+
export async function cachedAsync<A extends object, B>(obj: A, sym: symbol, fn: () => Promise<B>): Promise<B> {
18+
if (!(sym in obj)) {
19+
(obj as any)[sym] = await fn();
20+
}
21+
return (obj as any)[sym];
22+
}

packages/aws-cdk/lib/api/aws-auth/credential-plugins.ts

+111-24
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import type { AwsCredentialIdentity } from '@smithy/types';
1+
import { inspect } from 'util';
2+
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types';
23
import { debug, warning } from '../../logging';
3-
import { CredentialProviderSource, Mode, PluginHost } from '../plugin';
4+
import { CredentialProviderSource, PluginProviderResult, Mode, PluginHost, SDKv2CompatibleCredentials, SDKv3CompatibleCredentialProvider, SDKv3CompatibleCredentials } from '../plugin';
5+
import { credentialsAboutToExpire, makeCachingProvider } from './provider-caching';
46

57
/**
68
* Cache for credential providers.
@@ -15,9 +17,14 @@ import { CredentialProviderSource, Mode, PluginHost } from '../plugin';
1517
* for the given account.
1618
*/
1719
export class CredentialPlugins {
18-
private readonly cache: { [key: string]: PluginCredentials | undefined } = {};
20+
private readonly cache: { [key: string]: PluginCredentialsFetchResult | undefined } = {};
21+
private readonly host: PluginHost;
1922

20-
public async fetchCredentialsFor(awsAccountId: string, mode: Mode): Promise<PluginCredentials | undefined> {
23+
constructor(host?: PluginHost) {
24+
this.host = host ?? PluginHost.instance;
25+
}
26+
27+
public async fetchCredentialsFor(awsAccountId: string, mode: Mode): Promise<PluginCredentialsFetchResult | undefined> {
2128
const key = `${awsAccountId}-${mode}`;
2229
if (!(key in this.cache)) {
2330
this.cache[key] = await this.lookupCredentials(awsAccountId, mode);
@@ -26,13 +33,13 @@ export class CredentialPlugins {
2633
}
2734

2835
public get availablePluginNames(): string[] {
29-
return PluginHost.instance.credentialProviderSources.map((s) => s.name);
36+
return this.host.credentialProviderSources.map((s) => s.name);
3037
}
3138

32-
private async lookupCredentials(awsAccountId: string, mode: Mode): Promise<PluginCredentials | undefined> {
39+
private async lookupCredentials(awsAccountId: string, mode: Mode): Promise<PluginCredentialsFetchResult | undefined> {
3340
const triedSources: CredentialProviderSource[] = [];
3441
// Otherwise, inspect the various credential sources we have
35-
for (const source of PluginHost.instance.credentialProviderSources) {
42+
for (const source of this.host.credentialProviderSources) {
3643
let available: boolean;
3744
try {
3845
available = await source.isAvailable();
@@ -59,28 +66,108 @@ export class CredentialPlugins {
5966
continue;
6067
}
6168
debug(`Using ${source.name} credentials for account ${awsAccountId}`);
62-
const providerOrCreds = await source.getProvider(awsAccountId, mode);
63-
64-
// Backwards compatibility: if the plugin returns a ProviderChain, resolve that chain.
65-
// Otherwise it must have returned credentials.
66-
const credentials = (providerOrCreds as any).resolvePromise
67-
? await (providerOrCreds as any).resolvePromise()
68-
: providerOrCreds;
69-
70-
// Another layer of backwards compatibility: in SDK v2, the credentials object
71-
// is both a container and a provider. So we need to force the refresh using getPromise.
72-
// In SDK v3, these two responsibilities are separate, and the getPromise doesn't exist.
73-
if ((credentials as any).getPromise) {
74-
await (credentials as any).getPromise();
75-
}
7669

77-
return { credentials, pluginName: source.name };
70+
return {
71+
credentials: await v3ProviderFromPlugin(() => source.getProvider(awsAccountId, mode, {
72+
supportsV3Providers: true,
73+
})),
74+
pluginName: source.name,
75+
};
7876
}
7977
return undefined;
8078
}
8179
}
8280

83-
export interface PluginCredentials {
84-
readonly credentials: AwsCredentialIdentity;
81+
/**
82+
* Result from trying to fetch credentials from the Plugin host
83+
*/
84+
export interface PluginCredentialsFetchResult {
85+
/**
86+
* SDK-v3 compatible credential provider
87+
*/
88+
readonly credentials: AwsCredentialIdentityProvider;
89+
90+
/**
91+
* Name of plugin that successfully provided credentials
92+
*/
8593
readonly pluginName: string;
8694
}
95+
96+
/**
97+
* Take a function that calls the plugin, and turn it into an SDKv3-compatible credential provider.
98+
*
99+
* What we will do is the following:
100+
*
101+
* - Query the plugin and see what kind of result it gives us.
102+
* - If the result is self-refreshing or doesn't need refreshing, we turn it into an SDKv3 provider
103+
* and return it directly.
104+
* * If the underlying return value is a provider, we will make it a caching provider
105+
* (because we can't know if it will cache by itself or not).
106+
* * If the underlying return value is a static credential, caching isn't relevant.
107+
* * If the underlying return value is V2 credentials, those have caching built-in.
108+
* - If the result is a static credential that expires, we will wrap it in an SDKv3 provider
109+
* that will query the plugin again when the credential expires.
110+
*/
111+
async function v3ProviderFromPlugin(producer: () => Promise<PluginProviderResult>): Promise<AwsCredentialIdentityProvider> {
112+
const initial = await producer();
113+
114+
if (isV3Provider(initial)) {
115+
// Already a provider, make caching
116+
return makeCachingProvider(initial);
117+
} else if (isV3Credentials(initial) && initial.expiration === undefined) {
118+
// Static credentials that don't need refreshing nor caching
119+
return () => Promise.resolve(initial);
120+
} else if (isV3Credentials(initial) && initial.expiration !== undefined) {
121+
// Static credentials that do need refreshing and caching
122+
return refreshFromPluginProvider(initial, producer);
123+
} else if (isV2Credentials(initial)) {
124+
// V2 credentials that refresh and cache themselves
125+
return v3ProviderFromV2Credentials(initial);
126+
} else {
127+
throw new Error(`Plugin returned a value that doesn't resemble AWS credentials: ${inspect(initial)}`);
128+
}
129+
}
130+
131+
/**
132+
* Converts a V2 credential into a V3-compatible provider
133+
*/
134+
function v3ProviderFromV2Credentials(x: SDKv2CompatibleCredentials): AwsCredentialIdentityProvider {
135+
return async () => {
136+
// Get will fetch or refresh as necessary
137+
await x.getPromise();
138+
139+
return {
140+
accessKeyId: x.accessKeyId,
141+
secretAccessKey: x.secretAccessKey,
142+
sessionToken: x.sessionToken,
143+
expiration: x.expireTime,
144+
};
145+
};
146+
}
147+
148+
function refreshFromPluginProvider(current: AwsCredentialIdentity, producer: () => Promise<PluginProviderResult>): AwsCredentialIdentityProvider {
149+
return async () => {
150+
// eslint-disable-next-line no-console
151+
console.error(current, Date.now());
152+
if (credentialsAboutToExpire(current)) {
153+
const newCreds = await producer();
154+
if (!isV3Credentials(newCreds)) {
155+
throw new Error(`Plugin initially returned static V3 credentials but now returned something else: ${inspect(newCreds)}`);
156+
}
157+
current = newCreds;
158+
}
159+
return current;
160+
};
161+
}
162+
163+
function isV3Provider(x: PluginProviderResult): x is SDKv3CompatibleCredentialProvider {
164+
return typeof x === 'function';
165+
}
166+
167+
function isV2Credentials(x: PluginProviderResult): x is SDKv2CompatibleCredentials {
168+
return !!(x && typeof x === 'object' && x.accessKeyId && (x as SDKv2CompatibleCredentials).getPromise);
169+
}
170+
171+
function isV3Credentials(x: PluginProviderResult): x is SDKv3CompatibleCredentials {
172+
return !!(x && typeof x === 'object' && x.accessKeyId && !isV2Credentials(x));
173+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { memoize } from '@smithy/property-provider';
2+
import { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types';
3+
4+
/**
5+
* Wrap a credential provider in a cache
6+
*
7+
* Some credential providers in the SDKv3 are cached (the default Node
8+
* chain, specifically) but most others are not.
9+
*
10+
* Since we want to avoid duplicate calls to `AssumeRole`, or duplicate
11+
* MFA prompts or what have you, we are going to liberally wrap providers
12+
* in caches which will return the cached value until it expires.
13+
*/
14+
export function makeCachingProvider(provider: AwsCredentialIdentityProvider): AwsCredentialIdentityProvider {
15+
return memoize(
16+
provider,
17+
credentialsAboutToExpire,
18+
(token) => token.expiration !== undefined);
19+
}
20+
21+
export function credentialsAboutToExpire(token: AwsCredentialIdentity) {
22+
const expiryMarginSecs = 5;
23+
return token.expiration !== undefined && token.expiration.getTime() - Date.now() < expiryMarginSecs * 1000;
24+
}

packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts

+14-26
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import { Environment, EnvironmentUtils, UNKNOWN_ACCOUNT, UNKNOWN_REGION } from '
44
import { AssumeRoleCommandInput } from '@aws-sdk/client-sts';
55
import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';
66
import type { NodeHttpHandlerOptions } from '@smithy/node-http-handler';
7-
import { AwsCredentialIdentity, AwsCredentialIdentityProvider, Logger } from '@smithy/types';
7+
import { AwsCredentialIdentityProvider, Logger } from '@smithy/types';
88
import { AwsCliCompatible } from './awscli-compatible';
99
import { cached } from './cached';
1010
import { CredentialPlugins } from './credential-plugins';
1111
import { SDK } from './sdk';
1212
import { debug, warning } from '../../logging';
1313
import { traceMethods } from '../../util/tracing';
1414
import { Mode } from '../plugin';
15+
import { makeCachingProvider } from './provider-caching';
1516

1617
export type AssumeRoleAdditionalOptions = Partial<Omit<AssumeRoleCommandInput, 'ExternalId' | 'RoleArn'>>;
1718

@@ -57,7 +58,6 @@ export interface SdkHttpOptions {
5758
}
5859

5960
const CACHED_ACCOUNT = Symbol('cached_account');
60-
const CACHED_DEFAULT_CREDENTIALS = Symbol('cached_default_credentials');
6161

6262
/**
6363
* SDK configuration for a given environment
@@ -268,13 +268,7 @@ export class SdkProvider {
268268
public async defaultAccount(): Promise<Account | undefined> {
269269
return cached(this, CACHED_ACCOUNT, async () => {
270270
try {
271-
const credentials = await this.defaultCredentials();
272-
const accessKeyId = credentials.accessKeyId;
273-
if (!accessKeyId) {
274-
throw new Error('Unable to resolve AWS credentials (setup with "aws configure")');
275-
}
276-
277-
return await new SDK(credentials, this.defaultRegion, this.requestHandler, this.logger).currentAccount();
271+
return await new SDK(this.defaultCredentialProvider, this.defaultRegion, this.requestHandler, this.logger).currentAccount();
278272
} catch (e: any) {
279273
// Treat 'ExpiredToken' specially. This is a common situation that people may find themselves in, and
280274
// they are complaining about if we fail 'cdk synth' on them. We loudly complain in order to show that
@@ -307,7 +301,7 @@ export class SdkProvider {
307301
if (defaultAccountId === accountId) {
308302
return {
309303
source: 'correctDefault',
310-
credentials: await this.defaultCredentials(),
304+
credentials: await this.defaultCredentialProvider,
311305
};
312306
}
313307

@@ -322,7 +316,7 @@ export class SdkProvider {
322316
return {
323317
source: 'incorrectDefault',
324318
accountId: defaultAccountId,
325-
credentials: await this.defaultCredentials(),
319+
credentials: await this.defaultCredentialProvider,
326320
unusedPlugins: this.plugins.availablePluginNames,
327321
};
328322
}
@@ -334,16 +328,6 @@ export class SdkProvider {
334328
};
335329
}
336330

337-
/**
338-
* Resolve the default chain to the first set of credentials that is available
339-
*/
340-
private async defaultCredentials(): Promise<AwsCredentialIdentity> {
341-
return cached(this, CACHED_DEFAULT_CREDENTIALS, async () => {
342-
debug('Resolving default credentials');
343-
return this.defaultCredentialProvider();
344-
});
345-
}
346-
347331
/**
348332
* Return an SDK which uses assumed role credentials
349333
*
@@ -365,7 +349,7 @@ export class SdkProvider {
365349
const sourceDescription = fmtObtainedCredentials(mainCredentials);
366350

367351
try {
368-
const credentials = await fromTemporaryCredentials({
352+
const credentials = await makeCachingProvider(fromTemporaryCredentials({
369353
masterCredentials: mainCredentials.credentials,
370354
params: {
371355
RoleArn: roleArn,
@@ -380,7 +364,11 @@ export class SdkProvider {
380364
customUserAgent: 'aws-cdk',
381365
logger: this.logger,
382366
},
383-
})();
367+
logger: this.logger,
368+
}));
369+
370+
// Call the provider at least once here, to catch an error if it occurs
371+
await credentials();
384372

385373
return new SDK(credentials, region, this.requestHandler, this.logger);
386374
} catch (err: any) {
@@ -457,11 +445,11 @@ export interface CredentialsOptions {
457445
* Result of obtaining base credentials
458446
*/
459447
type ObtainBaseCredentialsResult =
460-
| { source: 'correctDefault'; credentials: AwsCredentialIdentity }
461-
| { source: 'plugin'; pluginName: string; credentials: AwsCredentialIdentity }
448+
| { source: 'correctDefault'; credentials: AwsCredentialIdentityProvider }
449+
| { source: 'plugin'; pluginName: string; credentials: AwsCredentialIdentityProvider }
462450
| {
463451
source: 'incorrectDefault';
464-
credentials: AwsCredentialIdentity;
452+
credentials: AwsCredentialIdentityProvider;
465453
accountId: string;
466454
unusedPlugins: string[];
467455
}

0 commit comments

Comments
 (0)