Skip to content

Commit f262d47

Browse files
authored
feat(auth): several UX improvements for browser login flows (#3198)
## Problems * It's not always obvious that the extension is waiting for the user to login * Some CodeCatalyst logic doesn't check for 'valid' connections prior to using them, creating noise * The "Copy Code" modal is sometimes shown twice when refreshing expired builder ID connections ## Solutions * Restrict connections to 1 re-auth flow at a time * Add some extra checks in CC logic to reduce noise * Show a progress notification while the user is connecting via the browser
1 parent 9dd8a88 commit f262d47

File tree

10 files changed

+152
-62
lines changed

10 files changed

+152
-62
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "auth: \"Copy Code\" modal is shown twice when refreshing expired IAM Identity Center connections"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "auth: verification codes for browser logins are now shown in a notification after opening the login URL"
4+
}

src/codecatalyst/explorer.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,11 @@ export class CodeCatalystRootNode implements RootNode {
148148
try {
149149
await this.authProvider.restore()
150150
const conn = this.authProvider.activeConnection
151-
const client = conn !== undefined ? await createClient(conn) : undefined
151+
if (conn !== undefined && this.authProvider.auth.getConnectionState(conn) === 'valid') {
152+
const client = await createClient(conn)
152153

153-
return client !== undefined ? await getConnectedDevEnv(client) : undefined
154+
return await getConnectedDevEnv(client)
155+
}
154156
} catch (err) {
155157
getLogger().warn(`codecatalyst: failed to get Dev Environment: ${UnknownError.cast(err).message}`)
156158
}

src/codecatalyst/reconnect.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const maxReconnectTime = 10 * 60 * 1000
2525
export function watchRestartingDevEnvs(ctx: ExtContext, authProvider: CodeCatalystAuthenticationProvider) {
2626
let restartHandled = false
2727
authProvider.onDidChangeActiveConnection(async conn => {
28-
if (restartHandled || conn === undefined) {
28+
if (restartHandled || conn === undefined || authProvider.auth.getConnectionState(conn) !== 'valid') {
2929
return
3030
}
3131

src/credentials/auth.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@ export class Auth implements AuthService, ConnectionManager {
331331
}
332332
}
333333

334+
public async reauthenticate({ id }: Pick<SsoConnection, 'id'>): Promise<SsoConnection>
335+
public async reauthenticate({ id }: Pick<IamConnection, 'id'>): Promise<IamConnection>
334336
public async reauthenticate({ id }: Pick<Connection, 'id'>): Promise<Connection> {
335337
const profile = this.store.getProfileOrThrow(id)
336338
if (profile.type === 'sso') {
@@ -347,7 +349,7 @@ export class Auth implements AuthService, ConnectionManager {
347349
}
348350

349351
public async useConnection({ id }: Pick<SsoConnection, 'id'>): Promise<SsoConnection>
350-
public async useConnection({ id }: Pick<Connection, 'id'>): Promise<Connection>
352+
public async useConnection({ id }: Pick<IamConnection, 'id'>): Promise<IamConnection>
351353
public async useConnection({ id }: Pick<Connection, 'id'>): Promise<Connection> {
352354
const profile = this.store.getProfile(id)
353355
if (profile === undefined) {
@@ -569,7 +571,7 @@ export class Auth implements AuthService, ConnectionManager {
569571
type: 'iam',
570572
state: profile.metadata.connectionState,
571573
label: profile.metadata.label ?? id,
572-
getCredentials: () => this.debouncedGetCredentials(id, provider),
574+
getCredentials: () => this.getCredentials(id, provider),
573575
}
574576
}
575577

@@ -591,11 +593,12 @@ export class Auth implements AuthService, ConnectionManager {
591593
startUrl: profile.startUrl,
592594
state: profile.metadata.connectionState,
593595
label: profile.metadata?.label ?? label,
594-
getToken: () => this.debouncedGetToken(id, provider),
596+
getToken: () => this.getToken(id, provider),
595597
}
596598
}
597599

598-
private async authenticate<T>(id: Connection['id'], callback: () => Promise<T>): Promise<T> {
600+
private readonly authenticate = keyedDebounce(this._authenticate.bind(this))
601+
private async _authenticate<T>(id: Connection['id'], callback: () => Promise<T>): Promise<T> {
599602
await this.updateConnectionState(id, 'authenticating')
600603

601604
try {
@@ -625,15 +628,15 @@ export class Auth implements AuthService, ConnectionManager {
625628
}
626629
}
627630

628-
private readonly debouncedGetToken = keyedDebounce(Auth.prototype.getToken.bind(this))
629-
private async getToken(id: Connection['id'], provider: SsoAccessTokenProvider): Promise<SsoToken> {
631+
private readonly getToken = keyedDebounce(this._getToken.bind(this))
632+
private async _getToken(id: Connection['id'], provider: SsoAccessTokenProvider): Promise<SsoToken> {
630633
const token = await provider.getToken()
631634

632635
return token ?? this.handleInvalidCredentials(id, () => provider.createToken())
633636
}
634637

635-
private readonly debouncedGetCredentials = keyedDebounce(Auth.prototype.getCredentials.bind(this))
636-
private async getCredentials(id: Connection['id'], provider: CredentialsProvider): Promise<Credentials> {
638+
private readonly getCredentials = keyedDebounce(this._getCredentials.bind(this))
639+
private async _getCredentials(id: Connection['id'], provider: CredentialsProvider): Promise<Credentials> {
637640
const credentials = await this.getCachedCredentials(provider)
638641
if (credentials !== undefined) {
639642
return credentials

src/credentials/providers/sharedCredentialsProvider.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,13 @@ export class SharedCredentialsProvider implements CredentialsProvider {
220220
const loadedCreds = await this.patchSourceCredentials()
221221

222222
const provider = chain(this.makeCredentialsProvider(loadedCreds))
223-
return resolveProviderWithCancel(this.profileName, provider())
223+
224+
// SSO profiles already show a notification, no need to show another
225+
if (isSsoProfile(this.profile)) {
226+
return provider()
227+
} else {
228+
return resolveProviderWithCancel(this.profileName, provider())
229+
}
224230
}
225231

226232
/**

src/credentials/sso/clients.ts

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,19 @@ import {
1616
SSOServiceException,
1717
} from '@aws-sdk/client-sso'
1818
import {
19-
AuthorizationPendingException,
2019
CreateTokenRequest,
2120
RegisterClientRequest,
22-
SlowDownException,
2321
SSOOIDC,
2422
StartDeviceAuthorizationRequest,
2523
} from '@aws-sdk/client-sso-oidc'
2624
import { AsyncCollection } from '../../shared/utilities/asyncCollection'
2725
import { pageableToCollection } from '../../shared/utilities/collectionUtils'
28-
import {
29-
assertHasProps,
30-
hasStringProps,
31-
isNonNullable,
32-
RequiredProps,
33-
selectFrom,
34-
} from '../../shared/utilities/tsUtils'
26+
import { assertHasProps, isNonNullable, RequiredProps, selectFrom } from '../../shared/utilities/tsUtils'
3527
import { getLogger } from '../../shared/logger'
3628
import { SsoAccessTokenProvider } from './ssoAccessTokenProvider'
37-
import { sleep } from '../../shared/utilities/timeoutUtils'
3829
import { isClientFault } from '../../shared/errors'
3930
import { DevSettings } from '../../shared/settings'
4031

41-
const backoffDelayMs = 5000
4232
export class OidcClient {
4333
public constructor(private readonly client: SSOOIDC, private readonly clock: { Date: typeof Date }) {}
4434

@@ -75,28 +65,6 @@ export class OidcClient {
7565
}
7666
}
7767

78-
public async pollForToken(request: CreateTokenRequest, timeout: number, interval = backoffDelayMs) {
79-
while (this.clock.Date.now() + interval <= timeout) {
80-
try {
81-
return await this.createToken(request)
82-
} catch (err) {
83-
if (!hasStringProps(err, 'name')) {
84-
throw err
85-
}
86-
87-
if (err instanceof SlowDownException) {
88-
interval += backoffDelayMs
89-
} else if (!(err instanceof AuthorizationPendingException)) {
90-
throw err
91-
}
92-
}
93-
94-
await sleep(interval)
95-
}
96-
97-
throw new Error('Timed-out waiting for authentication token')
98-
}
99-
10068
public static create(region: string) {
10169
return new this(
10270
new SSOOIDC({

src/credentials/sso/ssoAccessTokenProvider.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import globals from '../../shared/extensionGlobals'
6+
import * as nls from 'vscode-nls'
7+
const localize = nls.loadMessageBundle()
78

8-
import { SSOOIDCServiceException } from '@aws-sdk/client-sso-oidc'
9+
import globals from '../../shared/extensionGlobals'
10+
import * as vscode from 'vscode'
11+
import { AuthorizationPendingException, SlowDownException, SSOOIDCServiceException } from '@aws-sdk/client-sso-oidc'
912
import { openSsoPortalLink, SsoToken, ClientRegistration, isExpired, SsoProfile } from './model'
1013
import { getCache } from './cache'
11-
import { hasProps, RequiredProps, selectFrom } from '../../shared/utilities/tsUtils'
12-
import { CancellationError } from '../../shared/utilities/timeoutUtils'
14+
import { hasProps, hasStringProps, RequiredProps, selectFrom } from '../../shared/utilities/tsUtils'
15+
import { CancellationError, sleep } from '../../shared/utilities/timeoutUtils'
1316
import { OidcClient } from './clients'
1417
import { loadOr } from '../../shared/utilities/cacheUtils'
15-
import { isClientFault } from '../../shared/errors'
18+
import { isClientFault, ToolkitError } from '../../shared/errors'
1619

1720
const clientRegistrationType = 'public'
1821
const deviceGrantType = 'urn:ietf:params:oauth:grant-type:device_code'
@@ -156,12 +159,7 @@ export class SsoAccessTokenProvider {
156159
grantType: deviceGrantType,
157160
}
158161

159-
const token = await this.oidc.pollForToken(
160-
tokenRequest,
161-
registration.expiresAt.getTime(),
162-
authorization.interval
163-
)
164-
162+
const token = await pollForTokenWithProgress(() => this.oidc.createToken(tokenRequest), authorization)
165163
return this.formatToken(token, registration)
166164
}
167165

@@ -177,3 +175,56 @@ export class SsoAccessTokenProvider {
177175
return new this(profile)
178176
}
179177
}
178+
179+
const backoffDelayMs = 5000
180+
async function pollForTokenWithProgress<T>(
181+
fn: () => Promise<T>,
182+
authorization: Awaited<ReturnType<OidcClient['startDeviceAuthorization']>>,
183+
interval = authorization.interval ?? backoffDelayMs
184+
) {
185+
async function poll(token: vscode.CancellationToken) {
186+
while (
187+
authorization.expiresAt.getTime() - globals.clock.Date.now() > interval &&
188+
!token.isCancellationRequested
189+
) {
190+
try {
191+
return await fn()
192+
} catch (err) {
193+
if (!hasStringProps(err, 'name')) {
194+
throw err
195+
}
196+
197+
if (err instanceof SlowDownException) {
198+
interval += backoffDelayMs
199+
} else if (!(err instanceof AuthorizationPendingException)) {
200+
throw err
201+
}
202+
}
203+
204+
await sleep(interval)
205+
}
206+
207+
throw new ToolkitError('Timed-out waiting for browser login flow to complete', {
208+
code: 'TimedOut',
209+
})
210+
}
211+
212+
return vscode.window.withProgress(
213+
{
214+
title: localize(
215+
'AWS.auth.loginWithBrowser.messageDetail',
216+
'Login page opened in browser. When prompted, provide this code: {0}',
217+
authorization.userCode
218+
),
219+
cancellable: true,
220+
location: vscode.ProgressLocation.Notification,
221+
},
222+
(_, token) =>
223+
Promise.race([
224+
poll(token),
225+
new Promise<never>((_, reject) =>
226+
token.onCancellationRequested(() => reject(new CancellationError('user')))
227+
),
228+
])
229+
)
230+
}

src/test/credentials/auth.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ describe('Auth', function () {
3434

3535
function createTestTokenProvider() {
3636
let token: SsoToken | undefined
37+
let counter = 0
3738
const provider = stub(SsoAccessTokenProvider)
3839
provider.getToken.callsFake(async () => token)
3940
provider.createToken.callsFake(
40-
async () => (token = { accessToken: '123', expiresAt: new Date(Date.now() + 1000000) })
41+
async () => (token = { accessToken: String(++counter), expiresAt: new Date(Date.now() + 1000000) })
4142
)
4243
provider.invalidate.callsFake(async () => (token = undefined))
4344

@@ -185,6 +186,15 @@ describe('Auth', function () {
185186
assert.strictEqual(auth.activeConnection?.state, 'invalid')
186187
})
187188

189+
it('prevents concurrent `reauthenticate` operations on the same connection', async function () {
190+
const conn = await setupInvalidSsoConnection(auth, ssoProfile)
191+
await Promise.all([auth.reauthenticate(conn), auth.reauthenticate(conn)])
192+
const t1 = await conn.getToken()
193+
assert.strictEqual(t1.accessToken, '2', 'Only two tokens should have been created')
194+
const t3 = await auth.reauthenticate(conn).then(c => c.getToken())
195+
assert.notStrictEqual(t1.accessToken, t3.accessToken, 'Access tokens should change after `reauthenticate`')
196+
})
197+
188198
describe('SSO Connections', function () {
189199
async function runExpiredGetTokenFlow(conn: SsoConnection, selection: string | RegExp) {
190200
const token = conn.getToken()

0 commit comments

Comments
 (0)