@@ -29,7 +29,7 @@ import {
29
29
isNetworkError ,
30
30
} from '../../shared/errors'
31
31
import { getLogger } from '../../shared/logger'
32
- import { AwsLoginWithBrowser , AwsRefreshCredentials , Metric , telemetry } from '../../shared/telemetry/telemetry'
32
+ import { AwsLoginWithBrowser , AwsRefreshCredentials , telemetry } from '../../shared/telemetry/telemetry'
33
33
import { indent , toBase64URL } from '../../shared/utilities/textUtilities'
34
34
import { AuthSSOServer } from './server'
35
35
import { CancellationError , sleep } from '../../shared/utilities/timeoutUtils'
@@ -41,6 +41,7 @@ import { isRemoteWorkspace, isWebWorkspace } from '../../shared/vscode/env'
41
41
import { showInputBox } from '../../shared/ui/inputPrompter'
42
42
import { DevSettings } from '../../shared/settings'
43
43
import { onceChanged } from '../../shared/utilities/functionUtils'
44
+ import { NestedMap } from '../../shared/utilities/map'
44
45
45
46
export const authenticationPath = 'sso/authenticated'
46
47
@@ -67,16 +68,18 @@ export abstract class SsoAccessTokenProvider {
67
68
public constructor (
68
69
protected readonly profile : Pick < SsoProfile , 'startUrl' | 'region' | 'scopes' | 'identifier' > ,
69
70
protected readonly cache = getCache ( ) ,
70
- protected readonly oidc : OidcClient = OidcClient . create ( profile . region )
71
+ protected readonly oidc : OidcClient = OidcClient . create ( profile . region ) ,
72
+ protected readonly reAuthState : ReAuthState = ReAuthState . instance
71
73
) { }
72
74
73
- public async invalidate ( ) : Promise < void > {
75
+ public async invalidate ( reason : string ) : Promise < void > {
74
76
getLogger ( ) . info ( `SsoAccessTokenProvider: invalidate token and registration` )
75
77
// Use allSettled() instead of all() to ensure all clear() calls are resolved.
76
78
await Promise . allSettled ( [
77
79
this . cache . token . clear ( this . tokenCacheKey , 'SsoAccessTokenProvider.invalidate()' ) ,
78
80
this . cache . registration . clear ( this . registrationCacheKey , 'SsoAccessTokenProvider.invalidate()' ) ,
79
81
] )
82
+ this . reAuthState . set ( this . profile , { reAuthReason : `invalidate():${ reason } ` } )
80
83
}
81
84
82
85
public async getToken ( ) : Promise < SsoToken | undefined > {
@@ -99,23 +102,30 @@ export abstract class SsoAccessTokenProvider {
99
102
100
103
return refreshed . token
101
104
} else {
102
- await this . invalidate ( )
105
+ await this . invalidate ( 'allCacheExpired' )
103
106
}
104
107
}
105
108
106
- public async createToken ( ) : Promise < SsoToken > {
107
- const access = await this . runFlow ( )
109
+ public async createToken ( args ?: CreateTokenArgs ) : Promise < SsoToken > {
110
+ const access = await this . runFlow ( args )
108
111
const identity = this . tokenCacheKey
109
112
await this . cache . token . save ( identity , access )
110
113
await globals . globalState . setSsoSessionCreationDate ( this . tokenCacheKey , new globals . clock . Date ( ) )
111
114
112
115
return { ...access . token , identity }
113
116
}
114
117
115
- private async runFlow ( ) {
118
+ private async runFlow ( args ?: CreateTokenArgs ) {
116
119
const registration = await this . getValidatedClientRegistration ( )
117
120
try {
118
- return await this . authorize ( registration )
121
+ const result = await this . authorize ( registration , args )
122
+
123
+ // Authentication in the browser is successfully done, so the reauth reason is now stale.
124
+ // We don't clear the reason on failure since we want to keep reporting it as the reason until
125
+ // reauth is a success.
126
+ this . reAuthState . delete ( this . profile , 'reauth successful' )
127
+
128
+ return result
119
129
} catch ( err ) {
120
130
if ( err instanceof SSOOIDCServiceException && isClientFault ( err ) ) {
121
131
await this . cache . registration . clear (
@@ -150,9 +160,10 @@ export abstract class SsoAccessTokenProvider {
150
160
return refreshed
151
161
} catch ( err ) {
152
162
if ( ! isNetworkError ( err ) ) {
163
+ const reason = getTelemetryReason ( err )
153
164
telemetry . aws_refreshCredentials . emit ( {
154
165
result : getTelemetryResult ( err ) ,
155
- reason : getTelemetryReason ( err ) ,
166
+ reason,
156
167
reasonDesc : getTelemetryReasonDesc ( err ) ,
157
168
requestId : getRequestId ( err ) ,
158
169
...metric ,
@@ -163,6 +174,10 @@ export abstract class SsoAccessTokenProvider {
163
174
this . tokenCacheKey ,
164
175
`client fault: SSOOIDCServiceException: ${ err . message } `
165
176
)
177
+ // remember why refresh failed so next reauth flow will know why reauth is needed
178
+ if ( reason ) {
179
+ this . reAuthState . set ( this . profile , { reAuthReason : `refresh:${ reason } ` } )
180
+ }
166
181
}
167
182
}
168
183
@@ -185,33 +200,30 @@ export abstract class SsoAccessTokenProvider {
185
200
/**
186
201
* Wraps the given function with telemetry related to the browser login.
187
202
*/
188
- protected withBrowserLoginTelemetry < T extends ( ...args : any [ ] ) => any > ( func : T ) : ReturnType < T > {
189
- const run = ( span : Metric < AwsLoginWithBrowser > ) => {
203
+ protected withBrowserLoginTelemetry < T extends ( ...args : any [ ] ) => any > (
204
+ func : T ,
205
+ args ?: CreateTokenArgs
206
+ ) : ReturnType < T > {
207
+ return telemetry . aws_loginWithBrowser . run ( ( span ) => {
190
208
span . record ( {
191
209
credentialStartUrl : this . profile . startUrl ,
192
210
source : SsoAccessTokenProvider . _authSource ,
211
+ isReAuth : args ?. isReAuth ,
212
+ reAuthReason : args ?. isReAuth ? this . reAuthState . get ( this . profile ) . reAuthReason : undefined ,
193
213
} )
194
214
195
215
// Reset source in case there is a case where browser login was called but we forgot to set the source.
196
216
// We don't want to attribute the wrong source.
197
217
SsoAccessTokenProvider . authSource = 'unknown'
198
218
199
219
return func ( )
200
- }
201
-
202
- // During certain flows, eg reauthentication, we are already running within a span (run())
203
- // so we don't need to create a new one.
204
- const span = telemetry . spans . find ( ( s ) => s . name === 'aws_loginWithBrowser' )
205
- if ( span !== undefined ) {
206
- return run ( span as unknown as Metric < AwsLoginWithBrowser > )
207
- }
208
-
209
- return telemetry . aws_loginWithBrowser . run ( ( span ) => {
210
- return run ( span )
211
220
} )
212
221
}
213
222
214
- protected abstract authorize ( registration : ClientRegistration ) : Promise < {
223
+ protected abstract authorize (
224
+ registration : ClientRegistration ,
225
+ args ?: CreateTokenArgs
226
+ ) : Promise < {
215
227
token : SsoToken
216
228
registration : ClientRegistration
217
229
region : string
@@ -230,6 +242,7 @@ export abstract class SsoAccessTokenProvider {
230
242
profile : Pick < SsoProfile , 'startUrl' | 'region' | 'scopes' | 'identifier' > ,
231
243
cache = getCache ( ) ,
232
244
oidc : OidcClient = OidcClient . create ( profile . region ) ,
245
+ reAuthState ?: ReAuthState ,
233
246
useDeviceFlow : ( ) => boolean = ( ) => {
234
247
/**
235
248
* Device code flow is neccessary when:
@@ -242,15 +255,24 @@ export abstract class SsoAccessTokenProvider {
242
255
}
243
256
) {
244
257
if ( DevSettings . instance . get ( 'webAuth' , false ) && isWebWorkspace ( ) ) {
245
- return new WebAuthorization ( profile , cache , oidc )
258
+ return new WebAuthorization ( profile , cache , oidc , reAuthState )
246
259
}
247
260
if ( useDeviceFlow ( ) ) {
248
- return new DeviceFlowAuthorization ( profile , cache , oidc )
261
+ return new DeviceFlowAuthorization ( profile , cache , oidc , reAuthState )
249
262
}
250
- return new AuthFlowAuthorization ( profile , cache , oidc )
263
+ return new AuthFlowAuthorization ( profile , cache , oidc , reAuthState )
251
264
}
252
265
}
253
266
267
+ /**
268
+ * Supplementary arguments for the create token flow. This data can be used
269
+ * for things like telemetry.
270
+ */
271
+ export type CreateTokenArgs = {
272
+ /** true if the create token flow is for reauthentication */
273
+ isReAuth ?: boolean
274
+ }
275
+
254
276
const backoffDelayMs = 5000
255
277
async function pollForTokenWithProgress < T extends { requestId ?: string } > (
256
278
fn : ( ) => Promise < T > ,
@@ -356,14 +378,6 @@ function getSessionDuration(id: string) {
356
378
* - RefreshToken (optional)
357
379
*/
358
380
export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
359
- constructor (
360
- profile : Pick < SsoProfile , 'startUrl' | 'region' | 'scopes' | 'identifier' > ,
361
- cache = getCache ( ) ,
362
- oidc : OidcClient = OidcClient . create ( profile . region )
363
- ) {
364
- super ( profile , cache , oidc )
365
- }
366
-
367
381
override async registerClient ( ) : Promise < ClientRegistration > {
368
382
const companyName = getIdeProperties ( ) . company
369
383
return this . oidc . registerClient (
@@ -377,7 +391,8 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
377
391
}
378
392
379
393
override async authorize (
380
- registration : ClientRegistration
394
+ registration : ClientRegistration ,
395
+ args ?: CreateTokenArgs
381
396
) : Promise < { token : SsoToken ; registration : ClientRegistration ; region : string ; startUrl : string } > {
382
397
// This will NOT throw on expired clientId/Secret, but WILL throw on invalid clientId/Secret
383
398
const authorization = await this . oidc . startDeviceAuthorization ( {
@@ -403,7 +418,7 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
403
418
)
404
419
}
405
420
406
- const token = this . withBrowserLoginTelemetry ( ( ) => openBrowserAndWaitUntilComplete ( ) )
421
+ const token = this . withBrowserLoginTelemetry ( ( ) => openBrowserAndWaitUntilComplete ( ) , args )
407
422
408
423
return this . formatToken ( await token , registration )
409
424
}
@@ -419,7 +434,7 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
419
434
420
435
// Clear cached if registration is expired
421
436
if ( cachedRegistration && isExpired ( cachedRegistration ) ) {
422
- await this . invalidate ( )
437
+ await this . invalidate ( 'registrationExpired:DeviceCode' )
423
438
}
424
439
425
440
return loadOr ( this . cache . registration , cacheKey , ( ) => this . registerClient ( ) )
@@ -463,14 +478,6 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
463
478
* 2. If there is a problem, server responds with `invalid_grant` error.
464
479
*/
465
480
class AuthFlowAuthorization extends SsoAccessTokenProvider {
466
- constructor (
467
- profile : Pick < SsoProfile , 'startUrl' | 'region' | 'scopes' | 'identifier' > ,
468
- cache = getCache ( ) ,
469
- oidc : OidcClient
470
- ) {
471
- super ( profile , cache , oidc )
472
- }
473
-
474
481
override async registerClient ( ) : Promise < ClientRegistration > {
475
482
const companyName = getIdeProperties ( ) . company
476
483
return this . oidc . registerClient (
@@ -489,7 +496,8 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
489
496
}
490
497
491
498
override async authorize (
492
- registration : ClientRegistration
499
+ registration : ClientRegistration ,
500
+ args ?: CreateTokenArgs
493
501
) : Promise < { token : SsoToken ; registration : ClientRegistration ; region : string ; startUrl : string } > {
494
502
const state = randomUUID ( )
495
503
const authServer = AuthSSOServer . init ( state )
@@ -531,7 +539,7 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
531
539
telemetry . record ( { requestId : res . requestId } )
532
540
533
541
return res
534
- } )
542
+ } , args )
535
543
536
544
return this . formatToken ( token , registration )
537
545
} finally {
@@ -560,7 +568,7 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
560
568
561
569
// Clear cached if registration is expired or it uses a deprecate auth version (device code)
562
570
if ( cachedRegistration && ( isExpired ( cachedRegistration ) || isDeprecatedAuth ( cachedRegistration ) ) ) {
563
- await this . invalidate ( )
571
+ await this . invalidate ( 'registrationExpired:AuthFlow' )
564
572
}
565
573
566
574
return loadOr ( this . cache . registration , cacheKey , ( ) => this . registerClient ( ) )
@@ -575,14 +583,6 @@ class AuthFlowAuthorization extends SsoAccessTokenProvider {
575
583
class WebAuthorization extends SsoAccessTokenProvider {
576
584
private redirectUri = 'http://127.0.0.1:54321/oauth/callback'
577
585
578
- constructor (
579
- profile : Pick < SsoProfile , 'startUrl' | 'region' | 'scopes' | 'identifier' > ,
580
- cache = getCache ( ) ,
581
- oidc : OidcClient
582
- ) {
583
- super ( profile , cache , oidc )
584
- }
585
-
586
586
override async registerClient ( ) : Promise < ClientRegistration > {
587
587
const companyName = getIdeProperties ( ) . company
588
588
return this . oidc . registerClient (
@@ -601,7 +601,8 @@ class WebAuthorization extends SsoAccessTokenProvider {
601
601
}
602
602
603
603
override async authorize (
604
- registration : ClientRegistration
604
+ registration : ClientRegistration ,
605
+ args ?: CreateTokenArgs
605
606
) : Promise < { token : SsoToken ; registration : ClientRegistration ; region : string ; startUrl : string } > {
606
607
const state = randomUUID ( )
607
608
@@ -641,7 +642,7 @@ class WebAuthorization extends SsoAccessTokenProvider {
641
642
codeVerifier,
642
643
code : inputBox ,
643
644
} )
644
- } )
645
+ } , args )
645
646
646
647
return this . formatToken ( token , registration )
647
648
}
@@ -651,9 +652,46 @@ class WebAuthorization extends SsoAccessTokenProvider {
651
652
const cachedRegistration = await this . cache . registration . load ( cacheKey )
652
653
653
654
if ( cachedRegistration && ( isExpired ( cachedRegistration ) || cachedRegistration . flow !== 'web auth code' ) ) {
654
- await this . invalidate ( )
655
+ await this . invalidate ( 'registrationExpired:WebAuth' )
655
656
}
656
657
657
658
return loadOr ( this . cache . registration , cacheKey , ( ) => this . registerClient ( ) )
658
659
}
659
660
}
661
+
662
+ /**
663
+ * Remembers the reason an SSO session was put in to a "needs reauthentication" state.
664
+ * The current use is for telemetry. When the user reauths, we want {@link AwsLoginWithBrowser}
665
+ * to know why it needed to be reauthed.
666
+ *
667
+ * The flow is to use `set()` to remember why the user was put in to a reauth state,
668
+ * then upon the next reauth use `get()`. Finally, use `clear()` if the reauth is
669
+ * successful.
670
+ */
671
+ export class ReAuthState extends NestedMap < ReAuthStateKey , ReAuthStateValue > {
672
+ static #instance: ReAuthState
673
+ static get instance ( ) {
674
+ return ( this . #instance ??= new ReAuthState ( ) )
675
+ }
676
+ protected constructor ( ) {
677
+ super ( )
678
+ }
679
+
680
+ protected override hash ( profile : ReAuthStateKey ) : string {
681
+ return profile . identifier ?? profile . startUrl
682
+ }
683
+
684
+ protected override get name ( ) : string {
685
+ return ReAuthState . name
686
+ }
687
+
688
+ override get default ( ) : ReAuthStateValue {
689
+ return { reAuthReason : undefined }
690
+ }
691
+ }
692
+
693
+ type ReAuthStateKey = Pick < SsoProfile , 'identifier' | 'startUrl' >
694
+ type ReAuthStateValue = {
695
+ // the latest reason for why the connection was moved in to a "needs reauth" state
696
+ reAuthReason ?: string
697
+ }
0 commit comments