3
3
* SPDX-License-Identifier: Apache-2.0
4
4
*/
5
5
6
+ import globals from '../shared/extensionGlobals'
7
+
6
8
import * as nls from 'vscode-nls'
7
9
const localize = nls . loadMessageBundle ( )
8
10
@@ -15,21 +17,30 @@ import { Commands } from '../shared/vscode/commands2'
15
17
import { showQuickPick } from '../shared/ui/pickerPrompter'
16
18
import { isValidResponse } from '../shared/wizards/wizard'
17
19
import { CancellationError } from '../shared/utilities/timeoutUtils'
18
- import { ToolkitError } from '../shared/errors'
20
+ import { ToolkitError , UnknownError } from '../shared/errors'
19
21
import { getCache } from './sso/cache'
20
22
import { createFactoryFunction , Mutable } from '../shared/utilities/tsUtils'
21
23
import { SsoToken } from './sso/model'
22
- import globals from '../shared/extensionGlobals'
24
+ import { SsoClient } from './sso/clients'
25
+ import { getLogger } from '../shared/logger'
23
26
24
- interface SsoConnection {
27
+ export interface SsoConnection {
25
28
readonly type : 'sso'
26
29
readonly id : string
27
30
readonly label : string
31
+ readonly startUrl : string
28
32
readonly scopes ?: string [ ]
33
+
34
+ /**
35
+ * Retrieves a bearer token, refreshing or re-authenticating as-needed.
36
+ *
37
+ * This should be called for each new API request sent. It is up to the caller to
38
+ * handle cases where the service rejects the token.
39
+ */
29
40
getToken ( ) : Promise < Pick < SsoToken , 'accessToken' | 'expiresAt' > >
30
41
}
31
42
32
- interface IamConnection {
43
+ export interface IamConnection {
33
44
readonly type : 'iam'
34
45
readonly id : string
35
46
readonly label : string
@@ -51,13 +62,38 @@ export interface SsoProfile {
51
62
type Profile = SsoProfile
52
63
53
64
interface AuthService {
65
+ /**
66
+ * Lists all connections known to the Toolkit.
67
+ */
54
68
listConnections ( ) : Promise < Connection [ ] >
69
+
70
+ /**
71
+ * Creates a new connection using a profile.
72
+ *
73
+ * This will fail if the profile does not result in a valid connection.
74
+ */
55
75
createConnection ( profile : Profile ) : Promise < Connection >
76
+
77
+ /**
78
+ * Deletes the connection, removing all associated stateful resources.
79
+ */
56
80
deleteConnection ( connection : Pick < Connection , 'id' > ) : void
81
+
82
+ /**
83
+ * Retrieves a connection from an id if it exists.
84
+ *
85
+ * A connection id can be persisted and then later used to restore a previous connection.
86
+ * The caller is expected to handle the case where the connection no longer exists.
87
+ */
57
88
getConnection ( connection : Pick < Connection , 'id' > ) : Promise < Connection | undefined >
58
89
}
59
90
60
91
interface ConnectionManager {
92
+ /**
93
+ * The 'global' connection currently in use by the Toolkit.
94
+ *
95
+ * Connections can still be used even if they are not the active connection.
96
+ */
61
97
readonly activeConnection : Connection | undefined
62
98
readonly onDidChangeActiveConnection : vscode . Event < Connection | undefined >
63
99
@@ -81,7 +117,7 @@ interface ProfileMetadata {
81
117
* * `valid` -> `invalid` -> notify that the credentials are invalid, prompt to login again
82
118
* * `invalid` -> `invalid` -> immediately throw to stop the user from being spammed
83
119
*/
84
- readonly connectionState : 'valid' | 'invalid' | 'unauthenticated'
120
+ readonly connectionState : 'valid' | 'invalid' | 'unauthenticated' // 'authenticating'
85
121
}
86
122
87
123
type StoredProfile < T extends Profile = Profile > = T & { readonly metadata : ProfileMetadata }
@@ -93,6 +129,15 @@ export class ProfileStore {
93
129
return this . getData ( ) [ id ]
94
130
}
95
131
132
+ public getProfileOrThrow ( id : string ) : StoredProfile {
133
+ const profile = this . getProfile ( id )
134
+ if ( profile === undefined ) {
135
+ throw new Error ( `Profile does not exist: ${ id } ` )
136
+ }
137
+
138
+ return profile
139
+ }
140
+
96
141
public listProfiles ( ) : [ id : string , profile : StoredProfile ] [ ] {
97
142
return Object . entries ( this . getData ( ) )
98
143
}
@@ -106,10 +151,7 @@ export class ProfileStore {
106
151
}
107
152
108
153
public async updateProfile ( id : string , metadata : Partial < ProfileMetadata > ) : Promise < StoredProfile > {
109
- const profile = this . getProfile ( id )
110
- if ( profile === undefined ) {
111
- throw new Error ( `Profile does not exist: ${ id } ` )
112
- }
154
+ const profile = this . getProfileOrThrow ( id )
113
155
114
156
return this . putProfile ( id , { ...profile , metadata : { ...profile . metadata , ...metadata } } )
115
157
}
@@ -121,6 +163,14 @@ export class ProfileStore {
121
163
await this . updateData ( data )
122
164
}
123
165
166
+ public getCurrentProfileId ( ) : string | undefined {
167
+ return this . memento . get < string > ( 'auth.currentProfileId' )
168
+ }
169
+
170
+ public async setCurrentProfileId ( id : string | undefined ) : Promise < void > {
171
+ await this . memento . update ( 'auth.currentProfileId' , id )
172
+ }
173
+
124
174
private getData ( ) {
125
175
return this . memento . get < { readonly [ id : string ] : StoredProfile } > ( 'auth.profiles' , { } )
126
176
}
@@ -187,39 +237,66 @@ export class Auth implements AuthService, ConnectionManager {
187
237
public constructor (
188
238
private readonly store : ProfileStore ,
189
239
private readonly createTokenProvider = createFactoryFunction ( SsoAccessTokenProvider )
190
- ) { }
240
+ ) {
241
+ // TODO: do this lazily
242
+ this . restorePreviousSession ( ) . catch ( err => {
243
+ getLogger ( ) . warn ( `auth: failed to restore previous session: ${ UnknownError . cast ( err ) . message } ` )
244
+ } )
245
+ }
191
246
192
247
#activeConnection: Mutable < StatefulConnection > | undefined
193
248
public get activeConnection ( ) : StatefulConnection | undefined {
194
249
return this . #activeConnection
195
250
}
196
251
252
+ public async restorePreviousSession ( ) : Promise < void > {
253
+ const id = this . store . getCurrentProfileId ( )
254
+ if ( id === undefined ) {
255
+ return
256
+ }
257
+
258
+ await this . setActiveConnection ( id )
259
+ }
260
+
197
261
public async useConnection ( { id } : Pick < Connection , 'id' > ) : Promise < Connection > {
262
+ const conn = await this . setActiveConnection ( id )
263
+ if ( conn . state !== 'valid' ) {
264
+ await this . updateConnectionState ( id , 'unauthenticated' )
265
+ if ( conn . type === 'sso' ) {
266
+ await conn . getToken ( )
267
+ }
268
+ }
269
+
270
+ return conn
271
+ }
272
+
273
+ private async setActiveConnection ( id : Connection [ 'id' ] ) : Promise < StatefulConnection > {
198
274
const profile = this . store . getProfile ( id )
199
275
if ( profile === undefined ) {
200
276
throw new Error ( `Connection does not exist: ${ id } ` )
201
277
}
202
278
203
- const conn = this . createSsoConnection ( id , profile )
204
- this . #activeConnection = conn
205
-
206
- if ( conn . state !== 'valid' ) {
207
- await this . updateState ( id , 'unauthenticated' )
208
- await conn . getToken ( )
209
- } else {
210
- this . onDidChangeActiveConnectionEmitter . fire ( conn )
211
- }
279
+ const validated = await this . validateConnection ( id , profile )
280
+ const conn = ( this . #activeConnection = this . getSsoConnection ( id , validated ) )
281
+ this . onDidChangeActiveConnectionEmitter . fire ( conn )
282
+ await this . store . setCurrentProfileId ( id )
212
283
213
284
return conn
214
285
}
215
286
216
- public logout ( ) : void {
287
+ public async logout ( ) : Promise < void > {
288
+ if ( this . activeConnection === undefined ) {
289
+ return
290
+ }
291
+
292
+ await this . store . setCurrentProfileId ( undefined )
293
+ await this . invalidateConnection ( this . activeConnection . id )
217
294
this . #activeConnection = undefined
218
295
this . onDidChangeActiveConnectionEmitter . fire ( undefined )
219
296
}
220
297
221
298
public async listConnections ( ) : Promise < Connection [ ] > {
222
- return this . store . listProfiles ( ) . map ( ( [ id , profile ] ) => this . createSsoConnection ( id , profile ) )
299
+ return this . store . listProfiles ( ) . map ( ( [ id , profile ] ) => this . getSsoConnection ( id , profile ) )
223
300
}
224
301
225
302
// XXX: Used to combined scoped connections with the same startUrl into a single one
@@ -230,7 +307,7 @@ export class Auth implements AuthService, ConnectionManager {
230
307
. filter ( ( data ) : data is [ string , StoredProfile < SsoProfile > ] => data [ 1 ] . type === 'sso' )
231
308
. sort ( ( a , b ) => ( a [ 1 ] . scopes ?. length ?? 0 ) - ( b [ 1 ] . scopes ?. length ?? 0 ) )
232
309
. reduce (
233
- ( r , [ id , profile ] ) => ( r . set ( profile . startUrl , this . createSsoConnection ( id , profile ) ) , r ) ,
310
+ ( r , [ id , profile ] ) => ( r . set ( profile . startUrl , this . getSsoConnection ( id , profile ) ) , r ) ,
234
311
new Map < string , Connection > ( )
235
312
)
236
313
. values ( )
@@ -251,7 +328,7 @@ export class Auth implements AuthService, ConnectionManager {
251
328
// XXX: `id` should be based off the resolved `idToken`, _not_ the source profile
252
329
const id = getSsoProfileKey ( profile )
253
330
const storedProfile = await this . store . addProfile ( id , profile )
254
- const conn = this . createSsoConnection ( id , storedProfile )
331
+ const conn = this . getSsoConnection ( id , storedProfile )
255
332
256
333
try {
257
334
await conn . getToken ( )
@@ -260,14 +337,17 @@ export class Auth implements AuthService, ConnectionManager {
260
337
throw err
261
338
}
262
339
263
- return conn
340
+ return this . getSsoConnection ( id , storedProfile )
264
341
}
265
342
266
343
public async deleteConnection ( connection : Pick < Connection , 'id' > ) : Promise < void > {
267
- await this . store . deleteProfile ( connection . id )
268
344
if ( connection . id === this . #activeConnection?. id ) {
269
- this . logout ( )
345
+ await this . logout ( )
346
+ } else {
347
+ this . invalidateConnection ( connection . id )
270
348
}
349
+
350
+ await this . store . deleteProfile ( connection . id )
271
351
}
272
352
273
353
public async getConnection ( connection : Pick < Connection , 'id' > ) : Promise < Connection | undefined > {
@@ -276,7 +356,24 @@ export class Auth implements AuthService, ConnectionManager {
276
356
return connections . find ( c => c . id === connection . id )
277
357
}
278
358
279
- private async updateState ( id : Connection [ 'id' ] , connectionState : ProfileMetadata [ 'connectionState' ] ) {
359
+ private async invalidateConnection ( id : Connection [ 'id' ] ) {
360
+ const profile = this . store . getProfileOrThrow ( id )
361
+
362
+ if ( profile . type === 'sso' ) {
363
+ const provider = this . getTokenProvider ( id , profile )
364
+ const client = SsoClient . create ( profile . ssoRegion , provider )
365
+
366
+ // TODO: this seems to fail on the backend for scoped tokens
367
+ await client . logout ( ) . catch ( err => {
368
+ const name = profile . metadata . label ?? id
369
+ getLogger ( ) . warn ( `auth: failed to logout of connection "${ name } ": ${ UnknownError . cast ( err ) } ` )
370
+ } )
371
+
372
+ return provider . invalidate ( )
373
+ }
374
+ }
375
+
376
+ private async updateConnectionState ( id : Connection [ 'id' ] , connectionState : ProfileMetadata [ 'connectionState' ] ) {
280
377
const profile = await this . store . updateProfile ( id , { connectionState } )
281
378
282
379
if ( this . #activeConnection) {
@@ -287,11 +384,17 @@ export class Auth implements AuthService, ConnectionManager {
287
384
return profile
288
385
}
289
386
290
- private createSsoConnection (
291
- id : Connection [ 'id' ] ,
292
- profile : StoredProfile < SsoProfile >
293
- ) : SsoConnection & StatefulConnection {
294
- const provider = this . createTokenProvider (
387
+ private async validateConnection ( id : Connection [ 'id' ] , profile : StoredProfile ) {
388
+ const provider = this . getTokenProvider ( id , profile )
389
+ if ( profile . metadata . connectionState === 'valid' && ( await provider . getToken ( ) ) === undefined ) {
390
+ return this . updateConnectionState ( id , 'invalid' )
391
+ }
392
+
393
+ return profile
394
+ }
395
+
396
+ private getTokenProvider ( id : Connection [ 'id' ] , profile : StoredProfile < SsoProfile > ) {
397
+ return this . createTokenProvider (
295
398
{
296
399
identifier : id ,
297
400
startUrl : profile . startUrl ,
@@ -300,11 +403,19 @@ export class Auth implements AuthService, ConnectionManager {
300
403
} ,
301
404
this . ssoCache
302
405
)
406
+ }
407
+
408
+ private getSsoConnection (
409
+ id : Connection [ 'id' ] ,
410
+ profile : StoredProfile < SsoProfile >
411
+ ) : SsoConnection & StatefulConnection {
412
+ const provider = this . getTokenProvider ( id , profile )
303
413
304
414
return {
305
415
id,
306
416
type : profile . type ,
307
417
scopes : profile . scopes ,
418
+ startUrl : profile . startUrl ,
308
419
state : profile . metadata . connectionState ,
309
420
label : profile . metadata ?. label ?? `SSO (${ profile . startUrl } )` ,
310
421
getToken : ( ) => this . debouncedGetToken ( id , provider ) ,
@@ -318,9 +429,10 @@ export class Auth implements AuthService, ConnectionManager {
318
429
return token ?? this . handleInvalidCredentials ( id , ( ) => provider . createToken ( ) )
319
430
}
320
431
432
+ // TODO: split into 'promptInvalidCredentials' and 'authenticate' methods
321
433
private async handleInvalidCredentials < T > ( id : Connection [ 'id' ] , refresh : ( ) => Promise < T > ) : Promise < T > {
322
434
const previousState = this . store . getProfile ( id ) ?. metadata . connectionState
323
- await this . updateState ( id , 'invalid' )
435
+ await this . updateConnectionState ( id , 'invalid' )
324
436
325
437
if ( previousState === 'invalid' ) {
326
438
throw new ToolkitError ( 'Credentials are invalid or expired. Try logging in again.' , {
@@ -340,7 +452,7 @@ export class Auth implements AuthService, ConnectionManager {
340
452
}
341
453
342
454
const refreshed = await refresh ( )
343
- await this . updateState ( id , 'valid' )
455
+ await this . updateConnectionState ( id , 'valid' )
344
456
345
457
return refreshed
346
458
}
@@ -351,7 +463,7 @@ export class Auth implements AuthService, ConnectionManager {
351
463
}
352
464
}
353
465
354
- const loginCommand = Commands . register ( 'aws. auth.login' , async ( auth : Auth ) => {
466
+ export async function promptLogin ( auth : Auth ) {
355
467
const items = ( async function ( ) {
356
468
const connections = await auth . listMergedConnections ( )
357
469
@@ -367,7 +479,9 @@ const loginCommand = Commands.register('aws.auth.login', async (auth: Auth) => {
367
479
}
368
480
369
481
await auth . useConnection ( resp )
370
- } )
482
+ }
483
+
484
+ const loginCommand = Commands . register ( 'aws.auth.login' , promptLogin )
371
485
372
486
function mapEventType < T , U = void > ( event : vscode . Event < T > , fn ?: ( val : T ) => U ) : vscode . Event < U > {
373
487
const emitter = new vscode . EventEmitter < U > ( )
0 commit comments