Skip to content

Commit d01a5d5

Browse files
authored
feat(auth): persist active connection and add more docs (#2967)
## Problem The Auth service doesn't remember the active connection across sessions ## Solution Persist the connection id and restore it later. Originally I was going to implement something similar to `canAutoConnect` but just persisting the current connection is simpler and more user-friendly for the case of expired connections
1 parent a74e584 commit d01a5d5

File tree

4 files changed

+280
-63
lines changed

4 files changed

+280
-63
lines changed

src/credentials/auth.ts

Lines changed: 150 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

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

@@ -15,21 +17,30 @@ import { Commands } from '../shared/vscode/commands2'
1517
import { showQuickPick } from '../shared/ui/pickerPrompter'
1618
import { isValidResponse } from '../shared/wizards/wizard'
1719
import { CancellationError } from '../shared/utilities/timeoutUtils'
18-
import { ToolkitError } from '../shared/errors'
20+
import { ToolkitError, UnknownError } from '../shared/errors'
1921
import { getCache } from './sso/cache'
2022
import { createFactoryFunction, Mutable } from '../shared/utilities/tsUtils'
2123
import { SsoToken } from './sso/model'
22-
import globals from '../shared/extensionGlobals'
24+
import { SsoClient } from './sso/clients'
25+
import { getLogger } from '../shared/logger'
2326

24-
interface SsoConnection {
27+
export interface SsoConnection {
2528
readonly type: 'sso'
2629
readonly id: string
2730
readonly label: string
31+
readonly startUrl: string
2832
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+
*/
2940
getToken(): Promise<Pick<SsoToken, 'accessToken' | 'expiresAt'>>
3041
}
3142

32-
interface IamConnection {
43+
export interface IamConnection {
3344
readonly type: 'iam'
3445
readonly id: string
3546
readonly label: string
@@ -51,13 +62,38 @@ export interface SsoProfile {
5162
type Profile = SsoProfile
5263

5364
interface AuthService {
65+
/**
66+
* Lists all connections known to the Toolkit.
67+
*/
5468
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+
*/
5575
createConnection(profile: Profile): Promise<Connection>
76+
77+
/**
78+
* Deletes the connection, removing all associated stateful resources.
79+
*/
5680
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+
*/
5788
getConnection(connection: Pick<Connection, 'id'>): Promise<Connection | undefined>
5889
}
5990

6091
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+
*/
6197
readonly activeConnection: Connection | undefined
6298
readonly onDidChangeActiveConnection: vscode.Event<Connection | undefined>
6399

@@ -81,7 +117,7 @@ interface ProfileMetadata {
81117
* * `valid` -> `invalid` -> notify that the credentials are invalid, prompt to login again
82118
* * `invalid` -> `invalid` -> immediately throw to stop the user from being spammed
83119
*/
84-
readonly connectionState: 'valid' | 'invalid' | 'unauthenticated'
120+
readonly connectionState: 'valid' | 'invalid' | 'unauthenticated' // 'authenticating'
85121
}
86122

87123
type StoredProfile<T extends Profile = Profile> = T & { readonly metadata: ProfileMetadata }
@@ -93,6 +129,15 @@ export class ProfileStore {
93129
return this.getData()[id]
94130
}
95131

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+
96141
public listProfiles(): [id: string, profile: StoredProfile][] {
97142
return Object.entries(this.getData())
98143
}
@@ -106,10 +151,7 @@ export class ProfileStore {
106151
}
107152

108153
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)
113155

114156
return this.putProfile(id, { ...profile, metadata: { ...profile.metadata, ...metadata } })
115157
}
@@ -121,6 +163,14 @@ export class ProfileStore {
121163
await this.updateData(data)
122164
}
123165

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+
124174
private getData() {
125175
return this.memento.get<{ readonly [id: string]: StoredProfile }>('auth.profiles', {})
126176
}
@@ -187,39 +237,66 @@ export class Auth implements AuthService, ConnectionManager {
187237
public constructor(
188238
private readonly store: ProfileStore,
189239
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+
}
191246

192247
#activeConnection: Mutable<StatefulConnection> | undefined
193248
public get activeConnection(): StatefulConnection | undefined {
194249
return this.#activeConnection
195250
}
196251

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+
197261
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> {
198274
const profile = this.store.getProfile(id)
199275
if (profile === undefined) {
200276
throw new Error(`Connection does not exist: ${id}`)
201277
}
202278

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)
212283

213284
return conn
214285
}
215286

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)
217294
this.#activeConnection = undefined
218295
this.onDidChangeActiveConnectionEmitter.fire(undefined)
219296
}
220297

221298
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))
223300
}
224301

225302
// XXX: Used to combined scoped connections with the same startUrl into a single one
@@ -230,7 +307,7 @@ export class Auth implements AuthService, ConnectionManager {
230307
.filter((data): data is [string, StoredProfile<SsoProfile>] => data[1].type === 'sso')
231308
.sort((a, b) => (a[1].scopes?.length ?? 0) - (b[1].scopes?.length ?? 0))
232309
.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),
234311
new Map<string, Connection>()
235312
)
236313
.values()
@@ -251,7 +328,7 @@ export class Auth implements AuthService, ConnectionManager {
251328
// XXX: `id` should be based off the resolved `idToken`, _not_ the source profile
252329
const id = getSsoProfileKey(profile)
253330
const storedProfile = await this.store.addProfile(id, profile)
254-
const conn = this.createSsoConnection(id, storedProfile)
331+
const conn = this.getSsoConnection(id, storedProfile)
255332

256333
try {
257334
await conn.getToken()
@@ -260,14 +337,17 @@ export class Auth implements AuthService, ConnectionManager {
260337
throw err
261338
}
262339

263-
return conn
340+
return this.getSsoConnection(id, storedProfile)
264341
}
265342

266343
public async deleteConnection(connection: Pick<Connection, 'id'>): Promise<void> {
267-
await this.store.deleteProfile(connection.id)
268344
if (connection.id === this.#activeConnection?.id) {
269-
this.logout()
345+
await this.logout()
346+
} else {
347+
this.invalidateConnection(connection.id)
270348
}
349+
350+
await this.store.deleteProfile(connection.id)
271351
}
272352

273353
public async getConnection(connection: Pick<Connection, 'id'>): Promise<Connection | undefined> {
@@ -276,7 +356,24 @@ export class Auth implements AuthService, ConnectionManager {
276356
return connections.find(c => c.id === connection.id)
277357
}
278358

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']) {
280377
const profile = await this.store.updateProfile(id, { connectionState })
281378

282379
if (this.#activeConnection) {
@@ -287,11 +384,17 @@ export class Auth implements AuthService, ConnectionManager {
287384
return profile
288385
}
289386

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(
295398
{
296399
identifier: id,
297400
startUrl: profile.startUrl,
@@ -300,11 +403,19 @@ export class Auth implements AuthService, ConnectionManager {
300403
},
301404
this.ssoCache
302405
)
406+
}
407+
408+
private getSsoConnection(
409+
id: Connection['id'],
410+
profile: StoredProfile<SsoProfile>
411+
): SsoConnection & StatefulConnection {
412+
const provider = this.getTokenProvider(id, profile)
303413

304414
return {
305415
id,
306416
type: profile.type,
307417
scopes: profile.scopes,
418+
startUrl: profile.startUrl,
308419
state: profile.metadata.connectionState,
309420
label: profile.metadata?.label ?? `SSO (${profile.startUrl})`,
310421
getToken: () => this.debouncedGetToken(id, provider),
@@ -318,9 +429,10 @@ export class Auth implements AuthService, ConnectionManager {
318429
return token ?? this.handleInvalidCredentials(id, () => provider.createToken())
319430
}
320431

432+
// TODO: split into 'promptInvalidCredentials' and 'authenticate' methods
321433
private async handleInvalidCredentials<T>(id: Connection['id'], refresh: () => Promise<T>): Promise<T> {
322434
const previousState = this.store.getProfile(id)?.metadata.connectionState
323-
await this.updateState(id, 'invalid')
435+
await this.updateConnectionState(id, 'invalid')
324436

325437
if (previousState === 'invalid') {
326438
throw new ToolkitError('Credentials are invalid or expired. Try logging in again.', {
@@ -340,7 +452,7 @@ export class Auth implements AuthService, ConnectionManager {
340452
}
341453

342454
const refreshed = await refresh()
343-
await this.updateState(id, 'valid')
455+
await this.updateConnectionState(id, 'valid')
344456

345457
return refreshed
346458
}
@@ -351,7 +463,7 @@ export class Auth implements AuthService, ConnectionManager {
351463
}
352464
}
353465

354-
const loginCommand = Commands.register('aws.auth.login', async (auth: Auth) => {
466+
export async function promptLogin(auth: Auth) {
355467
const items = (async function () {
356468
const connections = await auth.listMergedConnections()
357469

@@ -367,7 +479,9 @@ const loginCommand = Commands.register('aws.auth.login', async (auth: Auth) => {
367479
}
368480

369481
await auth.useConnection(resp)
370-
})
482+
}
483+
484+
const loginCommand = Commands.register('aws.auth.login', promptLogin)
371485

372486
function mapEventType<T, U = void>(event: vscode.Event<T>, fn?: (val: T) => U): vscode.Event<U> {
373487
const emitter = new vscode.EventEmitter<U>()

src/credentials/sso/clients.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
GetRoleCredentialsRequest,
1111
ListAccountRolesRequest,
1212
ListAccountsRequest,
13+
LogoutRequest,
1314
RoleInfo,
1415
SSO,
1516
SSOServiceException,
@@ -161,6 +162,11 @@ export class SsoClient {
161162
}
162163
}
163164

165+
public async logout(request: Omit<LogoutRequest, OmittedProps> = {}) {
166+
const method = this.client.logout.bind(this.client)
167+
await this.call(method as ExtractOverload<typeof method>, request)
168+
}
169+
164170
private call<T extends { accessToken: string | undefined }, U>(
165171
method: (request: T) => Promise<U>,
166172
request: Omit<T, 'accessToken'>

0 commit comments

Comments
 (0)