Skip to content

Commit 0fbca15

Browse files
C Tiddjustinmk3
authored andcommitted
feat(codecatalyst): IdC connection logic #3986
Problem: Amazon CodeCatalyst currently supports Builder ID connections. The CodeCatalyst team is working to enable IAM Identity Center connections for CodeCatalyst, including enabling IDC connections using AWS Toolkit for Visual Studio Code. Solution: - Additions and changes within `CodeCatalystAuthenticationProvider` and surrounding modules to enable IDC connections. - The changes account for switching connections. - The changes account for extending scopes on existing connections. - Minimal UI additions to CodeCatalyst connection form to support IDC connection flow (may be revised in subsequent revision) - URI handler changes to support explicit IDC startUrl/region. - `tryAutoConnect` changes to support IDC profiles. Note that no changes were necessary for core authN/connection logic (e.g. secondaryAuth, and connection modules). Similarly, no UI changes were necessary for these existing connection flows. **The following testing has been performed:** - Manual testing of the connection with various permutations / combinations of Builder ID vs IDC connections for CodeCatalyst and CodeWhisperer. - Manual testing a CodeCatalyst IDC connection successfully authenticates and works against CodeCatalyst APIs. - Manual testing that a CodeCatalyst Dev Environment can be connected to from the Toolkit using an IDC conneciton. - End-to-end manual testing with a CodeCatalyst Dev Environment. - Backfilled some unit tests for `CodeCatalystAuthenticationProvider`. Co-authored-by: C Tidd <[email protected]> Author: C Tidd <[email protected]> commit 1eb8b94
1 parent 049152b commit 0fbca15

File tree

16 files changed

+461
-68
lines changed

16 files changed

+461
-68
lines changed

src/auth/auth.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import { CredentialsProviderManager } from './providers/credentialsProviderManag
2424
import { asString, CredentialsId, CredentialsProvider, fromString } from './providers/credentials'
2525
import { once } from '../shared/utilities/functionUtils'
2626
import { CredentialsSettings } from './credentials/utils'
27+
import {
28+
extractDataFromSection,
29+
getSectionOrThrow,
30+
loadSharedCredentialsSections,
31+
} from './credentials/sharedCredentials'
2732
import { getCodeCatalystDevEnvId } from '../shared/vscode/env'
2833
import { partition } from '../shared/utilities/mementos'
2934
import { SsoCredentialsProvider } from './providers/ssoCredentialsProvider'
@@ -47,8 +52,9 @@ import {
4752
StoredProfile,
4853
codecatalystScopes,
4954
createBuilderIdProfile,
55+
createSsoProfile,
5056
hasScopes,
51-
isBuilderIdConnection,
57+
isValidCodeCatalystConnection,
5258
loadIamProfilesIntoStore,
5359
loadLinkedProfilesIntoStore,
5460
ssoAccountAccessScopes,
@@ -521,9 +527,11 @@ export class Auth implements AuthService, ConnectionManager {
521527
}
522528

523529
// XXX: always read from the same location in a dev environment
524-
private getSsoSessionName = once(() => {
530+
// This detection is fuzzy if an sso-session section exists for any other reason.
531+
private detectSsoSessionNameForCodeCatalyst = once((): string => {
525532
try {
526533
const configFile = getConfigFilename()
534+
// `require('fs')` is workaround for web mode:
527535
const contents: string = require('fs').readFileSync(configFile, 'utf-8')
528536
const identifier = contents.match(/\[sso\-session (.*)\]/)?.[1]
529537
if (!identifier) {
@@ -532,21 +540,40 @@ export class Auth implements AuthService, ConnectionManager {
532540

533541
return identifier
534542
} catch (err) {
535-
const defaultName = 'codecatalyst'
536-
getLogger().warn(`auth: unable to get an sso session name, defaulting to "${defaultName}": %s`, err)
537-
538-
return defaultName
543+
const identifier = 'codecatalyst'
544+
getLogger().warn(`auth: unable to get an sso session name, defaulting to "${identifier}": %s`, err)
545+
return identifier
539546
}
540547
})
541548

549+
private createCodeCatalystDevEnvProfile = async (): Promise<[id: string, profile: SsoProfile]> => {
550+
const identifier = this.detectSsoSessionNameForCodeCatalyst()
551+
552+
const { sections } = await loadSharedCredentialsSections()
553+
const { sso_region: region, sso_start_url: startUrl } = extractDataFromSection(
554+
getSectionOrThrow(sections, identifier, 'sso-session')
555+
)
556+
557+
if ([region, startUrl].some(prop => typeof prop !== 'string')) {
558+
throw new ToolkitError('sso-session data missing in ~/.aws/config', { code: 'NoSsoSession' })
559+
}
560+
561+
return startUrl === builderIdStartUrl
562+
? [identifier, createBuilderIdProfile(codecatalystScopes)]
563+
: [identifier, createSsoProfile(region, startUrl, codecatalystScopes)]
564+
}
565+
542566
private getTokenProvider(id: Connection['id'], profile: StoredProfile<SsoProfile>) {
543-
// XXX: Use the token created by dev environments if and only if the profile is strictly for CodeCatalyst
567+
// XXX: Use the token created by Dev Environments if and only if the profile is strictly
568+
// for CodeCatalyst, as indicated by its set of scopes. A consequence of these semantics is
569+
// that any profile will be coerced to use this token if that profile exclusively contains
570+
// CodeCatalyst scopes. Similarly, if additional scopes are added to a profile, the profile
571+
// no longer matches this condition.
544572
const shouldUseSoftwareStatement =
545573
getCodeCatalystDevEnvId() !== undefined &&
546-
profile.startUrl === builderIdStartUrl &&
547574
profile.scopes?.every(scope => codecatalystScopes.includes(scope))
548575

549-
const tokenIdentifier = shouldUseSoftwareStatement ? this.getSsoSessionName() : id
576+
const tokenIdentifier = shouldUseSoftwareStatement ? this.detectSsoSessionNameForCodeCatalyst() : id
550577

551578
return this.createTokenProvider(
552579
{
@@ -702,15 +729,28 @@ export class Auth implements AuthService, ConnectionManager {
702729
})
703730
)
704731

705-
// Use the environment token if available
706-
// This token only has CC permissions currently!
732+
// When opening a Dev Environment, use the environment token if no other CodeCatalyst
733+
// credential is in use. This token only has CC permissions currently!
707734
if (getCodeCatalystDevEnvId() !== undefined) {
708-
const connections = (await this.listConnections()).filter(isBuilderIdConnection)
709-
710-
if (connections.length === 0) {
711-
const key = uuid.v4()
712-
await this.store.addProfile(key, createBuilderIdProfile(codecatalystScopes))
713-
await this.store.setCurrentProfileId(key)
735+
const connections = await this.listConnections()
736+
const shouldInsertDevEnvCredential = !connections.some(isValidCodeCatalystConnection)
737+
738+
if (shouldInsertDevEnvCredential) {
739+
// Insert a profile based on the `~/.aws/config` sso-session:
740+
try {
741+
// After creating a CodeCatalyst Dev Environment profile based on sso-session,
742+
// we discard the actual key, and we insert the profile with an arbitrary key.
743+
// The cache key for any strictly-CodeCatalyst profile is overriden to the
744+
// sso-session identifier on read. If the coerce-on-read semantics ever become
745+
// an issue, it should be possible to use the actual key here However, this
746+
// would require deleting existing profiles to avoid inserting duplicates.
747+
const [_dangerousDiscardActualKey, devEnvProfile] = await this.createCodeCatalystDevEnvProfile()
748+
const key = uuid.v4()
749+
await this.store.addProfile(key, devEnvProfile)
750+
await this.store.setCurrentProfileId(key)
751+
} catch (err) {
752+
getLogger().warn(`auth: failed to insert dev env profile: %s`, err)
753+
}
714754
}
715755
}
716756

src/auth/connection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const isIdcSsoConnection = (conn?: Connection): conn is SsoConnection =>
5252
export const isBuilderIdConnection = (conn?: Connection): conn is SsoConnection => isSsoConnection(conn, 'builderId')
5353

5454
export const isValidCodeCatalystConnection = (conn: Connection): conn is SsoConnection =>
55-
isBuilderIdConnection(conn) && hasScopes(conn, codecatalystScopes)
55+
isSsoConnection(conn) && hasScopes(conn, codecatalystScopes)
5656

5757
export function hasScopes(target: SsoConnection | SsoProfile, scopes: string[]): boolean {
5858
return scopes?.every(s => target.scopes?.includes(s))

src/auth/sso/cache.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,25 +105,28 @@ export function getTokenCache(directory = getCacheDir()): KeyedCache<SsoAccess>
105105
}
106106

107107
const logger = (message: string) => getLogger().debug(`SSO token cache: ${message}`)
108-
const cache = createDiskCache<StoredToken, string>((ssoUrl: string) => getTokenCacheFile(directory, ssoUrl), logger)
108+
const cache = createDiskCache<StoredToken, string>((key: string) => getTokenCacheFile(directory, key), logger)
109109

110110
return mapCache(cache, read, write)
111111
}
112112

113-
function getTokenCacheFile(ssoCacheDir: string, ssoUrl: string): string {
114-
const encoded = encodeURI(ssoUrl)
113+
function getTokenCacheFile(ssoCacheDir: string, key: string): string {
114+
const encoded = encodeURI(key)
115115
// Per the spec: 'SSO Login Token Flow' the access token must be
116116
// cached as the SHA1 hash of the bytes of the UTF-8 encoded
117-
// startUrl value with ".json" appended to the end.
117+
// startUrl value with ".json" appended to the end. However, the
118+
// cache key used by the Toolkit is an alternative arbitrary key
119+
// in most scenarios. This alternative cache key still conforms
120+
// to the same ${sha1(key)}.json cache location semantics.
118121

119122
const shasum = crypto.createHash('sha1')
120123
// Suppress warning because:
121124
// 1. SHA1 is prescribed by the AWS SSO spec
122-
// 2. the hashed startUrl value is not a secret
125+
// 2. the hashed startUrl or other key value is not a secret
123126
shasum.update(encoded) // lgtm[js/weak-cryptographic-algorithm]
124-
const hashedUrl = shasum.digest('hex') // lgtm[js/weak-cryptographic-algorithm]
127+
const hashedKey = shasum.digest('hex') // lgtm[js/weak-cryptographic-algorithm]
125128

126-
return path.join(ssoCacheDir, `${hashedUrl}.json`)
129+
return path.join(ssoCacheDir, `${hashedKey}.json`)
127130
}
128131

129132
function getRegistrationCacheFile(ssoCacheDir: string, key: RegistrationKey): string {

src/auth/ui/vue/authForms/manageIdentityCenter.vue

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,45 @@ export class CodeWhispererIdentityCenterState extends BaseIdentityCenterState {
360360
}
361361
}
362362
363+
export class CodeCatalystIdentityCenterState extends BaseIdentityCenterState {
364+
override get id(): AuthFormId {
365+
return 'identityCenterCodeCatalyst'
366+
}
367+
368+
override get name(): string {
369+
return 'CodeCatalyst'
370+
}
371+
372+
override get uiClickOpenId(): AuthUiClick {
373+
return 'auth_openCodeCatalyst'
374+
}
375+
376+
override get uiClickSignout(): AuthUiClick {
377+
return 'auth_codecatalyst_signoutIdentityCenter'
378+
}
379+
380+
override get featureType(): FeatureId {
381+
return 'codecatalyst'
382+
}
383+
384+
protected override async _startIdentityCenterSetup(): Promise<AuthError | undefined> {
385+
const data = await this.getSubmittableDataOrThrow()
386+
return client.startCodeCatalystIdentityCenterSetup(data.startUrl, data.region)
387+
}
388+
389+
override async isAuthConnected(): Promise<boolean> {
390+
return client.isCodeCatalystIdentityCenterConnected()
391+
}
392+
393+
override async showView(): Promise<void> {
394+
client.showCodeCatalystNode()
395+
}
396+
397+
override signout(): Promise<void> {
398+
return client.signoutCodeCatalystIdentityCenter()
399+
}
400+
}
401+
363402
/**
364403
* In the context of the Explorer, an Identity Center connection
365404
* is not required to be active. This is due to us only needing

src/auth/ui/vue/authForms/shared.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
<script lang="ts">
22
import { CodeCatalystBuilderIdState, CodeWhispererBuilderIdState } from './manageBuilderId.vue'
33
import { CredentialsState } from './manageCredentials.vue'
4-
import { CodeWhispererIdentityCenterState, ExplorerIdentityCenterState } from './manageIdentityCenter.vue'
4+
import {
5+
CodeCatalystIdentityCenterState,
6+
CodeWhispererIdentityCenterState,
7+
ExplorerIdentityCenterState,
8+
} from './manageIdentityCenter.vue'
59
import { AuthFormId } from './types'
610
711
/**
@@ -12,6 +16,7 @@ const authFormsState = {
1216
builderIdCodeWhisperer: CodeWhispererBuilderIdState.instance,
1317
builderIdCodeCatalyst: CodeCatalystBuilderIdState.instance,
1418
identityCenterCodeWhisperer: new CodeWhispererIdentityCenterState(),
19+
identityCenterCodeCatalyst: new CodeCatalystIdentityCenterState(),
1520
identityCenterExplorer: new ExplorerIdentityCenterState(),
1621
} as const
1722

src/auth/ui/vue/authForms/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type AuthFormId =
88
| 'builderIdCodeWhisperer'
99
| 'builderIdCodeCatalyst'
1010
| 'identityCenterCodeWhisperer'
11+
| 'identityCenterCodeCatalyst'
1112
| 'identityCenterExplorer'
1213
| 'aggregateExplorer'
1314

@@ -19,6 +20,7 @@ export const AuthFormDisplayName: Record<AuthFormId, string> = {
1920
credentials: 'IAM Credentials',
2021
builderIdCodeCatalyst: 'CodeCatalyst with AWS Builder ID',
2122
builderIdCodeWhisperer: 'CodeWhisperer with AWS Builder ID',
23+
identityCenterCodeCatalyst: 'CodeCatalyst with IAM Identity Center',
2224
identityCenterCodeWhisperer: 'CodeWhisperer with IAM Identity Center',
2325
identityCenterExplorer: 'AWS Explorer with IAM Identity Center',
2426
aggregateExplorer: '',

src/auth/ui/vue/serviceItemContent/codeCatalystContent.vue

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,34 +32,62 @@
3232
@auth-connection-updated="onAuthConnectionUpdated"
3333
></BuilderIdForm>
3434
</div>
35+
36+
<div>
37+
<div v-on:click="toggleIdentityCenterShown" style="cursor: pointer; display: flex; flex-direction: row">
38+
<div style="font-weight: bold; font-size: medium" :class="identityCenterCollapsibleClass"></div>
39+
<div>
40+
<div style="font-weight: bold; font-size: 14px">Sign in with IAM Identity Center.</div>
41+
</div>
42+
</div>
43+
</div>
44+
45+
<IdentityCenterForm
46+
:state="identityCenterState"
47+
:allow-existing-start-url="true"
48+
@auth-connection-updated="onAuthConnectionUpdated"
49+
v-show="isIdentityCenterShown"
50+
></IdentityCenterForm>
3551
</div>
3652
</div>
3753
</template>
3854

3955
<script lang="ts">
4056
import { defineComponent } from 'vue'
4157
import BuilderIdForm, { CodeCatalystBuilderIdState } from '../authForms/manageBuilderId.vue'
58+
import IdentityCenterForm, { CodeCatalystIdentityCenterState } from '../authForms/manageIdentityCenter.vue'
4259
import BaseServiceItemContent from './baseServiceItemContent.vue'
4360
import authFormsState, { AuthForm, FeatureStatus } from '../authForms/shared.vue'
4461
import { AuthFormId } from '../authForms/types'
4562
import { ConnectionUpdateArgs } from '../authForms/baseAuth.vue'
63+
import { WebviewClientFactory } from '../../../../webviews/client'
64+
import { AuthWebview } from '../show'
65+
66+
const client = WebviewClientFactory.create<AuthWebview>()
4667
4768
export default defineComponent({
4869
name: 'CodeCatalystContent',
49-
components: { BuilderIdForm },
70+
components: { BuilderIdForm, IdentityCenterForm },
5071
extends: BaseServiceItemContent,
5172
data() {
5273
return {
5374
isLoaded: {
5475
builderIdCodeCatalyst: false,
5576
} as Record<AuthFormId, boolean>,
5677
isAllAuthsLoaded: false,
78+
isIdentityCenterShown: false,
5779
}
5880
},
5981
computed: {
6082
builderIdState(): CodeCatalystBuilderIdState {
6183
return authFormsState.builderIdCodeCatalyst
6284
},
85+
identityCenterState(): CodeCatalystIdentityCenterState {
86+
return authFormsState.identityCenterCodeCatalyst
87+
},
88+
identityCenterCollapsibleClass() {
89+
return this.isIdentityCenterShown ? 'icon icon-vscode-chevron-down' : 'icon icon-vscode-chevron-right'
90+
},
6391
},
6492
methods: {
6593
updateIsAllAuthsLoaded() {
@@ -71,12 +99,18 @@ export default defineComponent({
7199
this.updateIsAllAuthsLoaded()
72100
this.emitAuthConnectionUpdated('codecatalyst', args)
73101
},
102+
toggleIdentityCenterShown() {
103+
this.isIdentityCenterShown = !this.isIdentityCenterShown
104+
if (this.isIdentityCenterShown) {
105+
client.emitUiClick('auth_codecatalyst_expandIAMIdentityCenter')
106+
}
107+
},
74108
},
75109
})
76110
77111
export class CodeCatalystContentState extends FeatureStatus {
78112
override getAuthForms(): AuthForm[] {
79-
return [authFormsState.builderIdCodeCatalyst]
113+
return [authFormsState.builderIdCodeCatalyst, authFormsState.identityCenterCodeCatalyst]
80114
}
81115
}
82116
</script>

0 commit comments

Comments
 (0)