Skip to content

Commit f484517

Browse files
authored
feat(CodeCatalyst): handle partial scope expiration #4557
Problem: Extended session duration (90 days) for CodeWhisperer is coming soon, but other scopes on the same SSO connection will not be affected, so there must be handling for "partial" expiration. Partial expiration is relevant when an SSO connection is authorized for both Q/CodeWhisperer and CodeCatalyst. Solution: When an AccessDeniedException (ADE) is encountered when making a CodeCatalyst call, it will clear the bearer token. When a bearer token is refreshed, it will call `CodeCatalystClientInternal.verifySession()` before making further calls. If the token was able to refresh, this means that the entire token was not expired, and that specific scopes are expired. 1. If `verifySession` fails due to an ADE, fire an EventEmitter. EventEmitters were used to prevent circular dependencies between `CodeCatalystAuthenticationProvider` and `CodeCatalystClientInternal`. I think it might be better long-term to have the client consume the auth provider, but it would have required a decent amount of refactoring. 2. `CodeCatalystAuthenticationProvider` subscribes to this event, handling the ADE. It will determine if the scope is partially expired, or if all scopes in the connection are expired. It will make a call to CodeWhisperer to determine this. 3. The CodeCatalyst context will be updated, displaying a "Re-Authorize" node in the explorer tree that matches the existing behavior in CodeWhisperer. 4. An information box will display informing the user that CodeCatalyst is expired. If the connection is partially expired, then it will also mention that CodeWhisperer is still useable. This notification will only display at most once per session, and has a "Don't show again" option. 6. On CC activation, check if the connection is shared with CodeWhisperer, and trigger a `verifySession` if it is. This shouldn't impact activation time of users that are not using CC and CW on the same connection. Additional Changes: 1. Moved `isInDevEnv` from `codecatalyst/utils` to `shared/vscode/env` to prevent circular dependencies. 2. Call `verifySession` during CodeCatalyst activation to detect scope expiration on startup 3. Created `showReauthenticateMessage` function in `utils/messages.ts` for shared functionality between CodeWhisperer and CodeCatalyst UX Impact: When the user is not using CodeWhisperer and CodeCatalyst on the same SSO profile, there will not be a UX impact. When the connection is still "valid" (able to refresh the access token), but code catalyst receives an access denied exception while attempting to call `verifySession`, the CC scope is considered expired. Anytime CC scope is expired, it will update the CodeCatalyst explorer to show this. If a user action triggered the CC call, a notification will appear telling the user to authenticate. If the connection was previously expired when starting VSCode, there will not be a notification. Testing: 1. Added debug commands to expire and un-expire tokens instantly by changing the bearerToken to an invalid token during request signing. 2. Performed testing on a feature-enabled IdC service with the upcoming extended duration changes. 2. Verified that all behavior is expected for just CodeWhisperer, CodeWhisperer & CodeCatalyst, and just CodeCatalyst
1 parent 7ff09e2 commit f484517

File tree

13 files changed

+236
-53
lines changed

13 files changed

+236
-53
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Support extended CodeWhisperer session durations when using CodeCatalyst on the same SSO connection"
4+
}

packages/core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@
209209
"amazonQWelcomePage": {
210210
"type": "boolean",
211211
"default": false
212+
},
213+
"codeCatalystConnectionExpired": {
214+
"type": "boolean",
215+
"default": false
212216
}
213217
},
214218
"additionalProperties": false

packages/core/src/auth/activation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { placeholder } from '../shared/vscode/commands2'
1212
import { getLogger } from '../shared/logger'
1313
import { ExtensionUse } from './utils'
1414
import { isCloud9 } from '../shared/extensionUtilities'
15-
import { isInDevEnv } from '../codecatalyst/utils'
15+
import { isInDevEnv } from '../shared/vscode/env'
1616
import { showManageConnections } from './ui/vue/show'
1717
import { isWeb } from '../common/webUtils'
1818

packages/core/src/codecatalyst/activation.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { PromptSettings } from '../shared/settings'
1818
import { dontShow } from '../shared/localizedText'
1919
import { getIdeProperties, isCloud9 } from '../shared/extensionUtilities'
2020
import { Commands, placeholder } from '../shared/vscode/commands2'
21-
import { getCodeCatalystConfig } from '../shared/clients/codecatalystClient'
21+
import { createClient, getCodeCatalystConfig } from '../shared/clients/codecatalystClient'
2222
import { isDevenvVscode } from './utils'
2323
import { getThisDevEnv } from './model'
2424
import { getLogger } from '../shared/logger/logger'
@@ -37,6 +37,17 @@ export async function activate(ctx: ExtContext): Promise<void> {
3737

3838
await authProvider.restore()
3939

40+
// if connection is shared with CodeWhisperer, check if CodeCatalyst scopes are expired
41+
if (authProvider.activeConnection && authProvider.isSharedConn()) {
42+
try {
43+
await createClient(authProvider.activeConnection, undefined, undefined, undefined, {
44+
showReauthPrompt: false,
45+
})
46+
} catch (err) {
47+
getLogger().info('codecatalyst: createClient failed during activation: %s', err)
48+
}
49+
}
50+
4051
ctx.extensionContext.subscriptions.push(
4152
uriHandlers.register(ctx.uriHandler, CodeCatalystCommands.declared),
4253
...Object.values(CodeCatalystCommands.declared).map(c => c.register(commands)),

packages/core/src/codecatalyst/auth.ts

Lines changed: 119 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import * as vscode from 'vscode'
7-
import { CodeCatalystClient, createClient } from '../shared/clients/codecatalystClient'
7+
import { onAccessDeniedException, CodeCatalystClient, createClient } from '../shared/clients/codecatalystClient'
88
import { Auth } from '../auth/auth'
99
import { getSecondaryAuth } from '../auth/secondaryAuth'
1010
import { getLogger } from '../shared/logger'
@@ -24,6 +24,9 @@ import {
2424
} from '../auth/connection'
2525
import { createBuilderIdConnection } from '../auth/utils'
2626
import { builderIdStartUrl } from '../auth/sso/model'
27+
import { codeWhispererClient } from '../codewhisperer/client/codewhisperer'
28+
import { AuthUtil as CodeWhispererAuth } from '../codewhisperer/util/authUtil'
29+
import { showReauthenticateMessage } from '../shared/utilities/messages'
2730

2831
// Secrets stored on the macOS keychain appear as individual entries for each key
2932
// This is fine so long as the user has only a few accounts. Otherwise this should
@@ -51,12 +54,23 @@ export function setCodeCatalystConnectedContext(isConnected: boolean) {
5154
return vscode.commands.executeCommand('setContext', 'aws.codecatalyst.connected', isConnected)
5255
}
5356

57+
type ConnectionState = {
58+
onboarded: boolean
59+
scopeExpired: boolean
60+
}
61+
5462
export class CodeCatalystAuthenticationProvider {
5563
public readonly onDidChangeActiveConnection = this.secondaryAuth.onDidChangeActiveConnection
64+
public readonly onAccessDeniedException = onAccessDeniedException
65+
private readonly onDidChangeEmitter = new vscode.EventEmitter<void>()
66+
public readonly onDidChange = this.onDidChangeEmitter.event
67+
68+
private readonly mementoKey = 'codecatalyst.connections'
5669

5770
public constructor(
5871
protected readonly storage: CodeCatalystAuthStorage,
5972
protected readonly memento: vscode.Memento,
73+
6074
public readonly auth = Auth.instance,
6175
public readonly secondaryAuth = getSecondaryAuth(
6276
auth,
@@ -65,8 +79,17 @@ export class CodeCatalystAuthenticationProvider {
6579
isValidCodeCatalystConnection
6680
)
6781
) {
68-
this.secondaryAuth.onDidChangeActiveConnection(async () => {
82+
this.onDidChangeActiveConnection(async () => {
83+
if (this.activeConnection) {
84+
await this.setScopeExpired(this.activeConnection, false)
85+
}
6986
await setCodeCatalystConnectedContext(this.isConnectionValid())
87+
this.onDidChangeEmitter.fire()
88+
})
89+
90+
this.onAccessDeniedException(async (showReauthPrompt: boolean) => {
91+
await this.accessDeniedExceptionHandler(showReauthPrompt)
92+
this.onDidChangeEmitter.fire()
7093
})
7194

7295
// set initial context in case event does not trigger
@@ -81,8 +104,27 @@ export class CodeCatalystAuthenticationProvider {
81104
return this.secondaryAuth.hasSavedConnection
82105
}
83106

107+
public async setScopeExpired(conn: SsoConnection, isExpired: boolean) {
108+
await this.updateConnectionState(conn, { scopeExpired: isExpired })
109+
}
110+
111+
public isScopeExpired(conn: SsoConnection): boolean {
112+
return this.getConnectionState(conn).scopeExpired
113+
}
114+
115+
public isSharedConn(): boolean {
116+
return (
117+
this.secondaryAuth.activeConnection !== undefined &&
118+
this.secondaryAuth.activeConnection?.id === CodeWhispererAuth.instance.secondaryAuth.activeConnection?.id
119+
)
120+
}
121+
84122
public isConnectionValid(): boolean {
85-
return this.activeConnection !== undefined && !this.secondaryAuth.isConnectionExpired
123+
return (
124+
this.activeConnection !== undefined &&
125+
!this.secondaryAuth.isConnectionExpired &&
126+
!this.isScopeExpired(this.activeConnection)
127+
)
86128
}
87129

88130
// Get rid of this? Not sure where to put PAT code.
@@ -118,6 +160,57 @@ export class CodeCatalystAuthenticationProvider {
118160
await this.secondaryAuth.restoreConnection()
119161
}
120162

163+
private async accessDeniedExceptionHandler(showReauthPrompt: boolean = true) {
164+
if (!this.isConnectionValid()) {
165+
return
166+
}
167+
168+
// Check if CodeWhisper and CodeCatalyst share the same connection
169+
const isSharedConn = this.isSharedConn()
170+
171+
if (isSharedConn) {
172+
await codeWhispererClient.listAvailableCustomizations()
173+
}
174+
175+
/*
176+
* Partial Expiration occurs when CodeWhisperer and CodeCatalyst are using
177+
* the same SSO connection, but CodeCatalyst scopes have expired before CodeWhisperer.
178+
*/
179+
const isPartialExpiration = isSharedConn && !CodeWhispererAuth.instance.isConnectionExpired()
180+
181+
getLogger().info(
182+
'auth: CodeCatalyst scopes are expired. shared=%s, partialExpiration=%s, showReauthPrompt=%s',
183+
isSharedConn,
184+
isPartialExpiration,
185+
showReauthPrompt
186+
)
187+
188+
await this.setScopeExpired(this.activeConnection!, true)
189+
await setCodeCatalystConnectedContext(this.isConnectionValid())
190+
191+
// showReauthPrompt is true primarily when a user interaction triggered the ADE
192+
if (showReauthPrompt) {
193+
void this.showReauthenticationPrompt(this.activeConnection!, isPartialExpiration)
194+
}
195+
}
196+
197+
public async showReauthenticationPrompt(conn: Connection, isPartialExpiration?: boolean): Promise<void> {
198+
const expiredMessage =
199+
'Connection expired. To continue using CodeCatalyst, connect with AWS Builder ID or AWS IAM Identity center.'
200+
const partiallyExpiredMessage =
201+
'CodeCatalyst connection has expired. Amazon Q/CodeWhisperer is still connected.'
202+
203+
await showReauthenticateMessage({
204+
message: isPartialExpiration ? partiallyExpiredMessage : expiredMessage,
205+
connect: 'Connect with AWS',
206+
doNotShow: "Don't Show Again",
207+
suppressId: 'codeCatalystConnectionExpired',
208+
reauthFunc: async () => {
209+
await this.auth.reauthenticate(conn)
210+
},
211+
})
212+
}
213+
121214
public async promptOnboarding(): Promise<void> {
122215
const message = `Using CodeCatalyst requires onboarding with a Space. Sign up with CodeCatalyst to get started.`
123216
const openBrowser = 'Open Browser'
@@ -280,28 +373,40 @@ export class CodeCatalystAuthenticationProvider {
280373
}
281374
}
282375

283-
public async isConnectionOnboarded(conn: SsoConnection, recheck = false) {
284-
const mementoKey = 'codecatalyst.connections'
285-
const getState = () => this.memento.get(mementoKey, {} as Record<string, { onboarded: boolean }>)
286-
const updateState = (state: { onboarded: boolean }) =>
287-
this.memento.update(mementoKey, {
288-
...getState(),
289-
[conn.id]: state,
290-
})
376+
private getState(): Record<string, ConnectionState> {
377+
return this.memento.get(this.mementoKey, {} as Record<string, ConnectionState>)
378+
}
379+
380+
public getConnectionState(conn: SsoConnection): ConnectionState {
381+
return this.getState()[conn.id]
382+
}
383+
384+
private async setConnectionState(conn: SsoConnection, state: ConnectionState) {
385+
await this.memento.update(this.mementoKey, {
386+
...this.getState(),
387+
[conn.id]: state,
388+
})
389+
}
291390

292-
const state = getState()[conn.id]
391+
private async updateConnectionState(conn: SsoConnection, state: Partial<ConnectionState>) {
392+
const initial = this.getConnectionState(conn)
393+
await this.setConnectionState(conn, { ...initial, ...state })
394+
}
395+
396+
public async isConnectionOnboarded(conn: SsoConnection, recheck = false) {
397+
const state = this.getConnectionState(conn)
293398
if (state !== undefined && !recheck) {
294399
return state.onboarded
295400
}
296401

297402
try {
298403
await createClient(conn)
299-
await updateState({ onboarded: true })
404+
await this.updateConnectionState(conn, { onboarded: true })
300405

301406
return true
302407
} catch (e) {
303408
if (isOnboardingException(e) && this.auth.getConnectionState(conn) === 'valid') {
304-
await updateState({ onboarded: false })
409+
await this.updateConnectionState(conn, { onboarded: false })
305410

306411
return false
307412
}

packages/core/src/codecatalyst/explorer.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,18 @@ async function getLocalCommands(auth: CodeCatalystAuthenticationProvider) {
4444
iconPath: getIcon('vscode-question'),
4545
})
4646

47-
if (!auth.activeConnection || !auth.isConnectionValid()) {
47+
// There is a connection, but it is expired, or CodeCatalyst scopes are expired.
48+
if (auth.activeConnection && !auth.isConnectionValid()) {
49+
return [
50+
reauth.build(auth.activeConnection, auth).asTreeNode({
51+
label: 'Re-authenticate to connect',
52+
iconPath: addColor(getIcon('vscode-debug-disconnect'), 'notificationsErrorIcon.foreground'),
53+
}),
54+
learnMoreNode,
55+
]
56+
}
57+
58+
if (!auth.activeConnection) {
4859
return [
4960
showManageConnections.build(placeholder, 'codecatalystDeveloperTools', 'codecatalyst').asTreeNode({
5061
label: 'Sign in to get started',
@@ -129,10 +140,9 @@ export class CodeCatalystRootNode implements TreeNode {
129140

130141
public constructor(private readonly authProvider: CodeCatalystAuthenticationProvider) {
131142
this.addRefreshEmitter(() => this.onDidChangeEmitter.fire())
132-
this.authProvider.onDidChangeActiveConnection(() => {
133-
for (const fire of this.refreshEmitters) {
134-
fire()
135-
}
143+
144+
this.authProvider.onDidChange(() => {
145+
this.refreshEmitters.forEach(fire => fire())
136146
})
137147
}
138148

packages/core/src/codecatalyst/utils.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { Ides } from 'aws-sdk/clients/codecatalyst'
77
import * as vscode from 'vscode'
88
import { CodeCatalystResource, getCodeCatalystConfig } from '../shared/clients/codecatalystClient'
99
import { pushIf } from '../shared/utilities/collectionUtils'
10-
import { getCodeCatalystDevEnvId } from '../shared/vscode/env'
1110
import { getLogger } from '../shared/logger'
1211

1312
/**
@@ -59,10 +58,3 @@ export function openCodeCatalystUrl(o: CodeCatalystResource) {
5958
export function isDevenvVscode(ides: Ides | undefined): boolean {
6059
return ides !== undefined && ides.findIndex(ide => ide.name === 'VSCode') !== -1
6160
}
62-
63-
/**
64-
* Returns true if we are in a dev env
65-
*/
66-
export function isInDevEnv(): boolean {
67-
return !!getCodeCatalystDevEnvId()
68-
}

packages/core/src/codewhisperer/util/authUtil.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { Auth } from '../../auth/auth'
99
import { ToolkitError } from '../../shared/errors'
1010
import { getSecondaryAuth } from '../../auth/secondaryAuth'
1111
import { isCloud9, isSageMaker } from '../../shared/extensionUtilities'
12-
import { PromptSettings } from '../../shared/settings'
1312
import {
1413
scopesCodeWhispererCore,
1514
createBuilderIdProfile,
@@ -33,6 +32,7 @@ import { GlobalState } from '../../shared/globalState'
3332
import { vsCodeState } from '../models/model'
3433
import { onceChanged } from '../../shared/utilities/functionUtils'
3534
import { indent } from '../../shared/utilities/textUtilities'
35+
import { showReauthenticateMessage } from '../../shared/utilities/messages'
3636

3737
/** Backwards compatibility for connections w pre-chat scopes */
3838
export const codeWhispererCoreScopes = [...scopesSsoAccountAccess, ...scopesCodeWhispererCore]
@@ -362,25 +362,20 @@ export class AuthUtil {
362362
}
363363

364364
public async showReauthenticatePrompt(isAutoTrigger?: boolean) {
365-
const settings = PromptSettings.instance
366-
const shouldShow = await settings.isPromptEnabled('codeWhispererConnectionExpired')
367-
if (!shouldShow || (isAutoTrigger && this.reauthenticatePromptShown)) {
365+
if (isAutoTrigger && this.reauthenticatePromptShown) {
368366
return
369367
}
370368

371-
await vscode.window
372-
.showInformationMessage(
373-
CodeWhispererConstants.connectionExpired,
374-
CodeWhispererConstants.connectWithAWSBuilderId,
375-
CodeWhispererConstants.DoNotShowAgain
376-
)
377-
.then(async resp => {
378-
if (resp === CodeWhispererConstants.connectWithAWSBuilderId) {
379-
await this.reauthenticate()
380-
} else if (resp === CodeWhispererConstants.DoNotShowAgain) {
381-
await settings.disablePrompt('codeWhispererConnectionExpired')
382-
}
383-
})
369+
await showReauthenticateMessage({
370+
message: CodeWhispererConstants.connectionExpired,
371+
connect: CodeWhispererConstants.connectWithAWSBuilderId,
372+
doNotShow: CodeWhispererConstants.DoNotShowAgain,
373+
suppressId: 'codeWhispererConnectionExpired',
374+
reauthFunc: async () => {
375+
await this.reauthenticate()
376+
},
377+
})
378+
384379
if (isAutoTrigger) {
385380
this.reauthenticatePromptShown = true
386381
}
@@ -457,7 +452,7 @@ export const AuthStates = {
457452
/**
458453
* The current connection exists, but needs to be reauthenticated for this feature to work
459454
*
460-
* Look to use {@link AuthUtil.reauthenticate}
455+
* Look to use {@link AuthUtil.reauthenticate()}
461456
*/
462457
expired: 'expired',
463458
/**

0 commit comments

Comments
 (0)