Skip to content

Commit 61c1138

Browse files
committed
Make AuthUtils session switch between SsoLogin, IamLogin, and undefined
1 parent eeebd0b commit 61c1138

File tree

8 files changed

+265
-38
lines changed

8 files changed

+265
-38
lines changed

packages/amazonq/test/e2e/amazonq/utils/setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@ export async function loginToIdC() {
2222
)
2323
}
2424

25-
await AuthUtil.instance.login(startUrl, region)
25+
await AuthUtil.instance.login(startUrl, region, 'sso')
2626
}

packages/amazonq/test/unit/codewhisperer/region/regionProfileManager.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ describe('RegionProfileManager', async function () {
2626

2727
async function setupConnection(type: 'builderId' | 'idc') {
2828
if (type === 'builderId') {
29-
await AuthUtil.instance.login(constants.builderIdStartUrl, region)
29+
await AuthUtil.instance.login(constants.builderIdStartUrl, region, 'sso')
3030
assert.ok(AuthUtil.instance.isSsoSession())
3131
assert.ok(AuthUtil.instance.isBuilderIdConnection())
3232
} else if (type === 'idc') {
33-
await AuthUtil.instance.login(enterpriseSsoStartUrl, region)
33+
await AuthUtil.instance.login(enterpriseSsoStartUrl, region, 'sso')
3434
assert.ok(AuthUtil.instance.isSsoSession())
3535
assert.ok(AuthUtil.instance.isIdcConnection())
3636
}

packages/core/src/auth/auth2.ts

Lines changed: 188 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,19 @@ export const LoginTypes = {
7070
} as const
7171
export type LoginType = (typeof LoginTypes)[keyof typeof LoginTypes]
7272

73-
interface BaseLogin {
74-
readonly loginType: LoginType
75-
}
76-
7773
export type cacheChangedEvent = 'delete' | 'create'
7874

79-
export type Login = SsoLogin // TODO: add IamLogin type when supported
75+
export type Login = SsoLogin | IamLogin
8076

8177
export type TokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoTokenSource
8278

79+
/**
80+
* Interface for authentication management
81+
*/
82+
interface BaseLogin {
83+
readonly loginType: LoginType
84+
}
85+
8386
/**
8487
* Handles auth requests to the Identity Server in the Amazon Q LSP.
8588
*/
@@ -188,7 +191,6 @@ export class LanguageClientAuth {
188191
*/
189192
export class SsoLogin implements BaseLogin {
190193
readonly loginType = LoginTypes.SSO
191-
private readonly eventEmitter = new vscode.EventEmitter<AuthStateEvent>()
192194

193195
// Cached information from the identity server for easy reference
194196
private ssoTokenId: string | undefined
@@ -199,7 +201,8 @@ export class SsoLogin implements BaseLogin {
199201

200202
constructor(
201203
public readonly profileName: string,
202-
private readonly lspAuth: LanguageClientAuth
204+
private readonly lspAuth: LanguageClientAuth,
205+
private readonly eventEmitter: vscode.EventEmitter<AuthStateEvent>
203206
) {
204207
lspAuth.registerSsoTokenChangedHandler((params: SsoTokenChangedParams) => this.ssoTokenChangedHandler(params))
205208
}
@@ -341,8 +344,184 @@ export class SsoLogin implements BaseLogin {
341344
return this.connectionState
342345
}
343346

344-
onDidChangeConnectionState(handler: (e: AuthStateEvent) => any) {
345-
return this.eventEmitter.event(handler)
347+
private updateConnectionState(state: AuthState) {
348+
const oldState = this.connectionState
349+
const newState = state
350+
351+
this.connectionState = newState
352+
353+
if (oldState !== newState) {
354+
this.eventEmitter.fire({ id: this.profileName, state: this.connectionState })
355+
}
356+
}
357+
358+
private ssoTokenChangedHandler(params: SsoTokenChangedParams) {
359+
if (params.ssoTokenId === this.ssoTokenId) {
360+
if (params.kind === SsoTokenChangedKind.Expired) {
361+
this.updateConnectionState('expired')
362+
return
363+
} else if (params.kind === SsoTokenChangedKind.Refreshed) {
364+
this.eventEmitter.fire({ id: this.profileName, state: 'refreshed' })
365+
}
366+
}
367+
}
368+
}
369+
370+
/**
371+
* Manages an IAM credentials connection.
372+
*/
373+
export class IamLogin implements BaseLogin {
374+
readonly loginType = LoginTypes.IAM
375+
376+
// Cached information from the identity server for easy reference
377+
private ssoTokenId: string | undefined
378+
private connectionState: AuthState = 'notConnected'
379+
private _data: { startUrl: string; region: string } | undefined
380+
381+
private cancellationToken: CancellationTokenSource | undefined
382+
383+
constructor(
384+
public readonly profileName: string,
385+
private readonly lspAuth: LanguageClientAuth,
386+
private readonly eventEmitter: vscode.EventEmitter<AuthStateEvent>
387+
) {
388+
lspAuth.registerSsoTokenChangedHandler((params: SsoTokenChangedParams) => this.ssoTokenChangedHandler(params))
389+
}
390+
391+
get data() {
392+
return this._data
393+
}
394+
395+
async login(opts: { accessKey: string; secretKey: string }) {
396+
// await this.updateProfile(opts)
397+
return this._getSsoToken(true)
398+
}
399+
400+
async reauthenticate() {
401+
if (this.connectionState === 'notConnected') {
402+
throw new ToolkitError('Cannot reauthenticate when not connected.')
403+
}
404+
return this._getSsoToken(true)
405+
}
406+
407+
async logout() {
408+
if (this.ssoTokenId) {
409+
await this.lspAuth.invalidateSsoToken(this.ssoTokenId)
410+
}
411+
this.updateConnectionState('notConnected')
412+
this._data = undefined
413+
// TODO: DeleteProfile api in Identity Service (this doesn't exist yet)
414+
}
415+
416+
async getProfile() {
417+
return await this.lspAuth.getProfile(this.profileName)
418+
}
419+
420+
async updateProfile(opts: { startUrl: string; region: string; scopes: string[] }) {
421+
await this.lspAuth.updateProfile(this.profileName, opts.startUrl, opts.region, opts.scopes)
422+
this._data = {
423+
startUrl: opts.startUrl,
424+
region: opts.region,
425+
}
426+
}
427+
428+
/**
429+
* Restore the connection state and connection details to memory, if they exist.
430+
*/
431+
async restore() {
432+
// const sessionData = await this.getProfile()
433+
// const ssoSession = sessionData?.ssoSession?.settings
434+
// if (ssoSession?.sso_region && ssoSession?.sso_start_url) {
435+
// this._data = {
436+
// startUrl: ssoSession.sso_start_url,
437+
// region: ssoSession.sso_region,
438+
// }
439+
// }
440+
// try {
441+
// await this._getSsoToken(false)
442+
// } catch (err) {
443+
// getLogger().error('Restoring connection failed: %s', err)
444+
// }
445+
}
446+
447+
/**
448+
* Cancels running active login flows.
449+
*/
450+
cancelLogin() {
451+
this.cancellationToken?.cancel()
452+
this.cancellationToken?.dispose()
453+
this.cancellationToken = undefined
454+
}
455+
456+
/**
457+
* Returns both the decrypted access token and the payload to send to the `updateCredentials` LSP API
458+
* with encrypted token
459+
*/
460+
async getToken() {
461+
const response = await this._getSsoToken(false)
462+
const decryptedKey = await jose.compactDecrypt(response.ssoToken.accessToken, this.lspAuth.encryptionKey)
463+
return {
464+
token: decryptedKey.plaintext.toString().replaceAll('"', ''),
465+
updateCredentialsParams: response.updateCredentialsParams,
466+
}
467+
}
468+
469+
/**
470+
* Returns the response from `getSsoToken` LSP API and sets the connection state based on the errors/result
471+
* of the call.
472+
*/
473+
private async _getSsoToken(login: boolean) {
474+
let response: GetSsoTokenResult
475+
this.cancellationToken = new CancellationTokenSource()
476+
477+
try {
478+
response = await this.lspAuth.getSsoToken(
479+
{
480+
/**
481+
* Note that we do not use SsoTokenSourceKind.AwsBuilderId here.
482+
* This is because it does not leave any state behind on disk, so
483+
* we cannot infer that a builder ID connection exists via the
484+
* Identity Server alone.
485+
*/
486+
kind: SsoTokenSourceKind.IamIdentityCenter,
487+
profileName: this.profileName,
488+
} satisfies IamIdentityCenterSsoTokenSource,
489+
login,
490+
this.cancellationToken.token
491+
)
492+
} catch (err: any) {
493+
switch (err.data?.awsErrorCode) {
494+
case AwsErrorCodes.E_CANCELLED:
495+
case AwsErrorCodes.E_SSO_SESSION_NOT_FOUND:
496+
case AwsErrorCodes.E_PROFILE_NOT_FOUND:
497+
case AwsErrorCodes.E_INVALID_SSO_TOKEN:
498+
this.updateConnectionState('notConnected')
499+
break
500+
case AwsErrorCodes.E_CANNOT_REFRESH_SSO_TOKEN:
501+
this.updateConnectionState('expired')
502+
break
503+
// TODO: implement when identity server emits E_NETWORK_ERROR, E_FILESYSTEM_ERROR
504+
// case AwsErrorCodes.E_NETWORK_ERROR:
505+
// case AwsErrorCodes.E_FILESYSTEM_ERROR:
506+
// // do stuff, probably nothing at all
507+
// break
508+
default:
509+
getLogger().error('SsoLogin: unknown error when requesting token: %s', err)
510+
break
511+
}
512+
throw err
513+
} finally {
514+
this.cancellationToken?.dispose()
515+
this.cancellationToken = undefined
516+
}
517+
518+
this.ssoTokenId = response.ssoToken.id
519+
this.updateConnectionState('connected')
520+
return response
521+
}
522+
523+
getConnectionState() {
524+
return this.connectionState
346525
}
347526

348527
private updateConnectionState(state: AuthState) {

packages/core/src/codewhisperer/ui/codeWhispererNodes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ export function createSignIn(): DataQuickPickItem<'signIn'> {
253253
if (isWeb()) {
254254
// TODO: nkomonen, call a Command instead
255255
onClick = () => {
256-
void AuthUtil.instance.login(builderIdStartUrl, builderIdRegion)
256+
void AuthUtil.instance.login(builderIdStartUrl, builderIdRegion, 'sso')
257257
}
258258
}
259259

0 commit comments

Comments
 (0)