Skip to content

Commit 481f71c

Browse files
authored
refactor(credentials): add assertion utils and clean-up SSO implementation (#2913)
## Problem * Duplicate 'missing props' implementations * SSO caching hard to track; no logging * Cache(s) not evicted on client faults ## Solution * Add several utils for checking or asserting that an object has certain properties * Add a basic factory for keyed caches on disk * Refactor SSO implementation to use the new utils
1 parent 2165825 commit 481f71c

27 files changed

+2008
-2676
lines changed

package-lock.json

Lines changed: 888 additions & 1676 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3293,8 +3293,8 @@
32933293
"webpack-dev-server": "^4.9.2"
32943294
},
32953295
"dependencies": {
3296-
"@aws-sdk/client-sso": "^3.54.0",
3297-
"@aws-sdk/client-sso-oidc": "^3.58.0",
3296+
"@aws-sdk/client-sso": "^3.181.0",
3297+
"@aws-sdk/client-sso-oidc": "^3.181.0",
32983298
"@aws-sdk/credential-provider-ini": "^3.46.0",
32993299
"@aws-sdk/credential-provider-process": "^3.15.0",
33003300
"@aws-sdk/credential-provider-sso": "^3.38.0",

src/credentials/loginManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export async function loginWithMostRecentCredentials(
164164
// 'provider' may be undefined if the last-used credentials no longer exists.
165165
if (!provider) {
166166
getLogger().warn('autoconnect: getCredentialsProvider() lookup failed for profile: %O', asString(creds))
167-
} else if (provider.canAutoConnect()) {
167+
} else if (await provider.canAutoConnect()) {
168168
if (!(await loginManager.login({ passive: true, providerId: creds }))) {
169169
getLogger().warn('autoconnect: failed to connect: "%s"', asString(creds))
170170
return false
@@ -224,7 +224,7 @@ export async function loginWithMostRecentCredentials(
224224
// Try to auto-connect any other non-default profile (useful for env vars, IMDS, Cloud9, ECS, …).
225225
const nonDefault = await findAsync(profileNames, async p => {
226226
const provider = await manager.getCredentialsProvider(providerMap[p])
227-
return p !== defaultName && !!provider?.canAutoConnect()
227+
return p !== defaultName && !!(await provider?.canAutoConnect())
228228
})
229229
if (nonDefault) {
230230
getLogger().info('autoconnect: trying "%s"', nonDefault)
@@ -309,7 +309,7 @@ function createCredentialsShim(
309309
credentialType = provider.getTelemetryType()
310310
credentialSourceId = credentialsProviderToTelemetryType(provider.getProviderType())
311311

312-
if (!provider.canAutoConnect()) {
312+
if (!(await provider.canAutoConnect())) {
313313
const message = localize('aws.credentials.expired', 'Credentials are expired or invalid, login again?')
314314
const resp = await vscode.window.showInformationMessage(message, localizedText.yes, localizedText.no)
315315

src/credentials/providers/credentials.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export interface CredentialsProvider {
113113
* first use (in particular, credentials that may prompt, such as SSO/MFA,
114114
* should _not_ attempt to auto-connect).
115115
*/
116-
canAutoConnect(): boolean
116+
canAutoConnect(): Promise<boolean>
117117
/**
118118
* Determines if the provider is currently capable of producing credentials.
119119
*/

src/credentials/providers/ec2CredentialsProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class Ec2CredentialsProvider implements CredentialsProvider {
8383
return getStringHash(this.getProviderType() + `-${this.createTime}`)
8484
}
8585

86-
public canAutoConnect(): boolean {
86+
public async canAutoConnect(): Promise<boolean> {
8787
return true
8888
}
8989

src/credentials/providers/ecsCredentialsProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export class EcsCredentialsProvider implements CredentialsProvider {
7676
return getStringHash(this.getProviderType() + `-${this.createTime}`)
7777
}
7878

79-
public canAutoConnect(): boolean {
79+
public async canAutoConnect(): Promise<boolean> {
8080
return true
8181
}
8282

src/credentials/providers/envVarsCredentialsProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class EnvVarsCredentialsProvider implements CredentialsProvider {
5151
return env.AWS_REGION
5252
}
5353

54-
public canAutoConnect(): boolean {
54+
public async canAutoConnect(): Promise<boolean> {
5555
return true
5656
}
5757

src/credentials/providers/sharedCredentialsProvider.ts

Lines changed: 82 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,20 @@ import * as AWS from '@aws-sdk/types'
77
import { AssumeRoleParams, fromIni } from '@aws-sdk/credential-provider-ini'
88
import { fromProcess } from '@aws-sdk/credential-provider-process'
99
import { ParsedIniData, SharedConfigFiles } from '@aws-sdk/shared-ini-file-loader'
10-
import { SSO } from '@aws-sdk/client-sso'
11-
import { SSOOIDC } from '@aws-sdk/client-sso-oidc'
1210
import { chain } from '@aws-sdk/property-provider'
1311
import { fromInstanceMetadata, fromContainerMetadata } from '@aws-sdk/credential-provider-imds'
1412
import { fromEnv } from '@aws-sdk/credential-provider-env'
15-
1613
import { Profile } from '../../shared/credentials/credentialsFile'
1714
import { getLogger } from '../../shared/logger'
1815
import { getStringHash } from '../../shared/utilities/textUtilities'
1916
import { getMfaTokenFromUser } from '../credentialsCreator'
20-
import { hasProfileProperty, resolveProviderWithCancel } from '../credentialsUtilities'
21-
import { SSO_PROFILE_PROPERTIES, validateSsoProfile } from '../sso/sso'
22-
import { DiskCache } from '../sso/diskCache'
23-
import { SsoAccessTokenProvider } from '../sso/ssoAccessTokenProvider'
17+
import { resolveProviderWithCancel } from '../credentialsUtilities'
2418
import { CredentialsProvider, CredentialsProviderType, CredentialsId } from './credentials'
25-
import { SsoCredentialProvider } from './ssoCredentialProvider'
2619
import { CredentialType } from '../../shared/telemetry/telemetry.gen'
20+
import { getMissingProps, hasProps } from '../../shared/utilities/tsUtils'
2721
import { DefaultStsClient } from '../../shared/clients/stsClient'
22+
import { SsoAccessTokenProvider } from '../sso/ssoAccessTokenProvider'
23+
import { SsoClient } from '../sso/clients'
2824

2925
const SHARED_CREDENTIAL_PROPERTIES = {
3026
AWS_ACCESS_KEY_ID: 'aws_access_key_id',
@@ -40,14 +36,31 @@ const SHARED_CREDENTIAL_PROPERTIES = {
4036
SSO_REGION: 'sso_region',
4137
SSO_ACCOUNT_ID: 'sso_account_id',
4238
SSO_ROLE_NAME: 'sso_role_name',
43-
}
39+
} as const
4440

4541
const CREDENTIAL_SOURCES = {
4642
ECS_CONTAINER: 'EcsContainer',
4743
EC2_INSTANCE_METADATA: 'Ec2InstanceMetadata',
4844
ENVIRONMENT: 'Environment',
4945
}
5046

47+
function validateProfile(profile: Profile, ...props: string[]): string | undefined {
48+
const missing = getMissingProps(profile, ...props)
49+
50+
if (missing.length !== 0) {
51+
return `missing properties: ${missing.join(', ')}`
52+
}
53+
}
54+
55+
function isSsoProfile(profile: Profile): boolean {
56+
return (
57+
hasProps(profile, SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL) ||
58+
hasProps(profile, SHARED_CREDENTIAL_PROPERTIES.SSO_REGION) ||
59+
hasProps(profile, SHARED_CREDENTIAL_PROPERTIES.SSO_ROLE_NAME) ||
60+
hasProps(profile, SHARED_CREDENTIAL_PROPERTIES.SSO_ACCOUNT_ID)
61+
)
62+
}
63+
5164
/**
5265
* Represents one profile from the AWS Shared Credentials files.
5366
*/
@@ -83,7 +96,7 @@ export class SharedCredentialsProvider implements CredentialsProvider {
8396
}
8497

8598
public getTelemetryType(): CredentialType {
86-
if (this.isSsoProfile()) {
99+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL)) {
87100
return 'ssoProfile'
88101
} else if (this.isCredentialSource(CREDENTIAL_SOURCES.EC2_INSTANCE_METADATA)) {
89102
return 'ec2Metadata'
@@ -103,12 +116,17 @@ export class SharedCredentialsProvider implements CredentialsProvider {
103116
return this.profile[SHARED_CREDENTIAL_PROPERTIES.REGION]
104117
}
105118

106-
public canAutoConnect(): boolean {
107-
// check if SSO token is still valid
108-
if (this.isSsoProfile()) {
109-
return !!new DiskCache().loadAccessToken(this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL]!)
119+
public async canAutoConnect(): Promise<boolean> {
120+
if (isSsoProfile(this.profile)) {
121+
const tokenProvider = new SsoAccessTokenProvider({
122+
region: this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_REGION]!,
123+
startUrl: this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL]!,
124+
})
125+
126+
return (await tokenProvider.getToken()) !== undefined
110127
}
111-
return !hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.MFA_SERIAL)
128+
129+
return !hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.MFA_SERIAL)
112130
}
113131

114132
public async isAvailable(): Promise<boolean> {
@@ -124,34 +142,34 @@ export class SharedCredentialsProvider implements CredentialsProvider {
124142
* Returns undefined if the Profile is valid, else a string indicating what is invalid
125143
*/
126144
public validate(): string | undefined {
127-
const expectedProperties: string[] = []
128-
129-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE)) {
145+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE)) {
130146
return this.validateSourcedCredentials()
131-
} else if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.ROLE_ARN)) {
147+
} else if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.ROLE_ARN)) {
132148
return this.validateSourceProfileChain()
133-
} else if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_PROCESS)) {
149+
} else if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_PROCESS)) {
134150
// No validation. Don't check anything else.
135151
return undefined
136-
} else if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_SESSION_TOKEN)) {
137-
expectedProperties.push(
152+
} else if (
153+
hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_ACCESS_KEY_ID) ||
154+
hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_SECRET_ACCESS_KEY) ||
155+
hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_SESSION_TOKEN)
156+
) {
157+
return validateProfile(
158+
this.profile,
138159
SHARED_CREDENTIAL_PROPERTIES.AWS_ACCESS_KEY_ID,
139160
SHARED_CREDENTIAL_PROPERTIES.AWS_SECRET_ACCESS_KEY
140161
)
141-
} else if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_ACCESS_KEY_ID)) {
142-
expectedProperties.push(SHARED_CREDENTIAL_PROPERTIES.AWS_SECRET_ACCESS_KEY)
143-
} else if (this.isSsoProfile()) {
144-
return validateSsoProfile(this.profile, this.profileName)
162+
} else if (isSsoProfile(this.profile)) {
163+
return validateProfile(
164+
this.profile,
165+
SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL,
166+
SHARED_CREDENTIAL_PROPERTIES.SSO_REGION,
167+
SHARED_CREDENTIAL_PROPERTIES.SSO_ROLE_NAME,
168+
SHARED_CREDENTIAL_PROPERTIES.SSO_ACCOUNT_ID
169+
)
145170
} else {
146-
return `Profile ${this.profileName} is not supported by the Toolkit.`
171+
return 'not supported by the Toolkit'
147172
}
148-
149-
const missingProperties = this.getMissingProperties(expectedProperties)
150-
if (missingProperties.length !== 0) {
151-
return `Profile ${this.profileName} is missing properties: ${missingProperties.join(', ')}`
152-
}
153-
154-
return undefined
155173
}
156174

157175
/**
@@ -164,7 +182,7 @@ export class SharedCredentialsProvider implements CredentialsProvider {
164182
* We can handle this resolution ourselves, giving the SDK the resolved credentials by 'pre-loading' them.
165183
*/
166184
private async patchSourceCredentials(): Promise<ParsedIniData | undefined> {
167-
if (!hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.SOURCE_PROFILE)) {
185+
if (!hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.SOURCE_PROFILE)) {
168186
return undefined
169187
}
170188

@@ -200,18 +218,8 @@ export class SharedCredentialsProvider implements CredentialsProvider {
200218

201219
const loadedCreds = await this.patchSourceCredentials()
202220

203-
// SSO entry point
204-
if (this.isSsoProfile()) {
205-
const ssoCredentialProvider = this.makeSsoProvider()
206-
return await ssoCredentialProvider.refreshCredentials()
207-
}
208-
209221
const provider = chain(this.makeCredentialsProvider(loadedCreds))
210-
return await resolveProviderWithCancel(this.profileName, provider())
211-
}
212-
213-
private getMissingProperties(propertyNames: string[]): string[] {
214-
return propertyNames.filter(propertyName => !this.profile[propertyName])
222+
return resolveProviderWithCancel(this.profileName, provider())
215223
}
216224

217225
/**
@@ -248,7 +256,7 @@ export class SharedCredentialsProvider implements CredentialsProvider {
248256
}
249257

250258
private validateSourcedCredentials(): string | undefined {
251-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.SOURCE_PROFILE)) {
259+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.SOURCE_PROFILE)) {
252260
return `credential_source and source_profile cannot both be set`
253261
}
254262

@@ -261,45 +269,66 @@ export class SharedCredentialsProvider implements CredentialsProvider {
261269
private makeCredentialsProvider(loadedCreds?: ParsedIniData): AWS.CredentialProvider {
262270
const logger = getLogger()
263271

264-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE)) {
272+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE)) {
265273
logger.verbose(
266274
`Profile ${this.profileName} contains ${SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE} - treating as Environment Credentials`
267275
)
268276
return this.makeSourcedCredentialsProvider()
269277
}
270278

271-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.ROLE_ARN)) {
279+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.ROLE_ARN)) {
272280
logger.verbose(
273281
`Profile ${this.profileName} contains ${SHARED_CREDENTIAL_PROPERTIES.ROLE_ARN} - treating as regular Shared Credentials`
274282
)
275283

276284
return this.makeSharedIniFileCredentialsProvider(loadedCreds)
277285
}
278286

279-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_PROCESS)) {
287+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_PROCESS)) {
280288
logger.verbose(
281289
`Profile ${this.profileName} contains ${SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_PROCESS} - treating as Process Credentials`
282290
)
283291

284292
return fromProcess({ profile: this.profileName })
285293
}
286294

287-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_SESSION_TOKEN)) {
295+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_SESSION_TOKEN)) {
288296
logger.verbose(
289297
`Profile ${this.profileName} contains ${SHARED_CREDENTIAL_PROPERTIES.AWS_SESSION_TOKEN} - treating as regular Shared Credentials`
290298
)
291299

292300
return this.makeSharedIniFileCredentialsProvider(loadedCreds)
293301
}
294302

295-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_ACCESS_KEY_ID)) {
303+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.AWS_ACCESS_KEY_ID)) {
296304
logger.verbose(
297305
`Profile ${this.profileName} contains ${SHARED_CREDENTIAL_PROPERTIES.AWS_ACCESS_KEY_ID} - treating as regular Shared Credentials`
298306
)
299307

300308
return this.makeSharedIniFileCredentialsProvider(loadedCreds)
301309
}
302310

311+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL)) {
312+
logger.verbose(
313+
`Profile ${this.profileName} contains ${SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL} - treating as SSO Credentials`
314+
)
315+
316+
const region = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_REGION]!
317+
const startUrl = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL]!
318+
const accountId = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_ACCOUNT_ID]!
319+
const roleName = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_ROLE_NAME]!
320+
const tokenProvider = new SsoAccessTokenProvider({ region, startUrl })
321+
const client = SsoClient.create(region, tokenProvider)
322+
323+
return async () => {
324+
if ((await tokenProvider.getToken()) === undefined) {
325+
await tokenProvider.createToken()
326+
}
327+
328+
return client.getRoleCredentials({ accountId, roleName })
329+
}
330+
}
331+
303332
logger.error(`Profile ${this.profileName} did not contain any supported properties`)
304333
throw new Error(`Shared Credentials profile ${this.profileName} is not supported`)
305334
}
@@ -343,32 +372,8 @@ export class SharedCredentialsProvider implements CredentialsProvider {
343372
)
344373
}
345374

346-
private makeSsoProvider() {
347-
// These properties are validated before reaching this method
348-
const ssoRegion = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_REGION]!
349-
const ssoUrl = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_START_URL]!
350-
351-
const ssoOidcClient = new SSOOIDC({ region: ssoRegion })
352-
const cache = new DiskCache()
353-
const ssoAccessTokenProvider = new SsoAccessTokenProvider(ssoRegion, ssoUrl, ssoOidcClient, cache)
354-
355-
const ssoClient = new SSO({ region: ssoRegion })
356-
const ssoAccount = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_ACCOUNT_ID]!
357-
const ssoRole = this.profile[SHARED_CREDENTIAL_PROPERTIES.SSO_ROLE_NAME]!
358-
return new SsoCredentialProvider(ssoAccount, ssoRole, ssoClient, ssoAccessTokenProvider)
359-
}
360-
361-
public isSsoProfile(): boolean {
362-
for (const propertyName of SSO_PROFILE_PROPERTIES) {
363-
if (hasProfileProperty(this.profile, propertyName)) {
364-
return true
365-
}
366-
}
367-
return false
368-
}
369-
370375
private isCredentialSource(source: string): boolean {
371-
if (hasProfileProperty(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE)) {
376+
if (hasProps(this.profile, SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE)) {
372377
return this.profile[SHARED_CREDENTIAL_PROPERTIES.CREDENTIAL_SOURCE] === source
373378
}
374379
return false

0 commit comments

Comments
 (0)