Skip to content

Commit 8e2eb23

Browse files
committed
feat(core): Add new auth class using flare identity server for AmazonQ
1 parent 191c504 commit 8e2eb23

File tree

4 files changed

+874
-19
lines changed

4 files changed

+874
-19
lines changed

packages/core/src/auth/auth2.ts

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import * as jose from 'jose'
8+
import {
9+
GetSsoTokenParams,
10+
getSsoTokenRequestType,
11+
GetSsoTokenResult,
12+
IamIdentityCenterSsoTokenSource,
13+
InvalidateSsoTokenParams,
14+
invalidateSsoTokenRequestType,
15+
ProfileKind,
16+
UpdateProfileParams,
17+
updateProfileRequestType,
18+
SsoTokenChangedParams,
19+
ssoTokenChangedRequestType,
20+
AwsBuilderIdSsoTokenSource,
21+
UpdateCredentialsParams,
22+
AwsErrorCodes,
23+
SsoTokenSourceKind,
24+
listProfilesRequestType,
25+
ListProfilesResult,
26+
UpdateProfileResult,
27+
InvalidateSsoTokenResult,
28+
AuthorizationFlowKind,
29+
CancellationToken,
30+
CancellationTokenSource,
31+
bearerCredentialsDeleteNotificationType,
32+
bearerCredentialsUpdateRequestType,
33+
SsoTokenChangedKind,
34+
} from '@aws/language-server-runtimes/protocol'
35+
import { LanguageClient } from 'vscode-languageclient'
36+
import { getLogger } from '../shared/logger/logger'
37+
import { ToolkitError } from '../shared/errors'
38+
import { useDeviceFlow } from './sso/ssoAccessTokenProvider'
39+
40+
export const AuthStates = {
41+
NOT_CONNECTED: 'notConnected',
42+
CONNECTED: 'connected',
43+
EXPIRED: 'expired',
44+
} as const
45+
export type AuthState = (typeof AuthStates)[keyof typeof AuthStates]
46+
47+
export type AuthStateEvent = { id: string; state: AuthState | 'refreshed' }
48+
49+
export const LoginTypes = {
50+
SSO: 'sso',
51+
IAM: 'iam',
52+
} as const
53+
export type LoginType = (typeof LoginTypes)[keyof typeof LoginTypes]
54+
55+
interface BaseLogin {
56+
readonly loginType: LoginType
57+
}
58+
59+
export type Login = SsoLogin // TODO: add IamLogin type when supported
60+
61+
export type TokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoTokenSource
62+
63+
/**
64+
* Handles auth requests to the Identity Server in the Amazon Q LSP.
65+
*/
66+
export class LanguageClientAuth {
67+
constructor(
68+
private readonly client: LanguageClient,
69+
private readonly clientName: string,
70+
public readonly encryptionKey: Buffer
71+
) {}
72+
73+
getSsoToken(
74+
tokenSource: TokenSource,
75+
login: boolean = false,
76+
cancellationToken?: CancellationToken
77+
): Promise<GetSsoTokenResult> {
78+
return this.client.sendRequest(
79+
getSsoTokenRequestType.method,
80+
{
81+
clientName: this.clientName,
82+
source: tokenSource,
83+
options: {
84+
loginOnInvalidToken: login,
85+
authorizationFlow: useDeviceFlow() ? AuthorizationFlowKind.DeviceCode : AuthorizationFlowKind.Pkce,
86+
},
87+
} satisfies GetSsoTokenParams,
88+
cancellationToken
89+
)
90+
}
91+
92+
updateProfile(
93+
profileName: string,
94+
startUrl: string,
95+
region: string,
96+
scopes: string[]
97+
): Promise<UpdateProfileResult> {
98+
return this.client.sendRequest(updateProfileRequestType.method, {
99+
profile: {
100+
kinds: [ProfileKind.SsoTokenProfile],
101+
name: profileName,
102+
settings: {
103+
region,
104+
sso_session: profileName,
105+
},
106+
},
107+
ssoSession: {
108+
name: profileName,
109+
settings: {
110+
sso_region: region,
111+
sso_start_url: startUrl,
112+
sso_registration_scopes: scopes,
113+
},
114+
},
115+
} satisfies UpdateProfileParams)
116+
}
117+
118+
listProfiles() {
119+
return this.client.sendRequest(listProfilesRequestType.method, {}) as Promise<ListProfilesResult>
120+
}
121+
122+
/**
123+
* Returns a profile by name along with its linked sso_session.
124+
* Does not currently exist as an API in the Identity Service.
125+
*/
126+
async getProfile(profileName: string) {
127+
const response = await this.listProfiles()
128+
const profile = response.profiles.find((profile) => profile.name === profileName)
129+
const ssoSession = profile?.settings?.sso_session
130+
? response.ssoSessions.find((session) => session.name === profile!.settings!.sso_session)
131+
: undefined
132+
133+
return { profile, ssoSession }
134+
}
135+
136+
updateBearerToken(request: UpdateCredentialsParams) {
137+
return this.client.sendRequest(bearerCredentialsUpdateRequestType.method, request)
138+
}
139+
140+
deleteBearerToken() {
141+
return this.client.sendNotification(bearerCredentialsDeleteNotificationType.method)
142+
}
143+
144+
invalidateSsoToken(tokenId: string) {
145+
return this.client.sendRequest(invalidateSsoTokenRequestType.method, {
146+
ssoTokenId: tokenId,
147+
} satisfies InvalidateSsoTokenParams) as Promise<InvalidateSsoTokenResult>
148+
}
149+
150+
registerSsoTokenChangedHandler(ssoTokenChangedHandler: (params: SsoTokenChangedParams) => any) {
151+
this.client.onNotification(ssoTokenChangedRequestType.method, ssoTokenChangedHandler)
152+
}
153+
}
154+
155+
/**
156+
* Manages an SSO connection.
157+
*/
158+
export class SsoLogin implements BaseLogin {
159+
readonly loginType = LoginTypes.SSO
160+
private readonly eventEmitter = new vscode.EventEmitter<AuthStateEvent>()
161+
162+
// Cached information from the identity server for easy reference
163+
private ssoTokenId: string | undefined
164+
private connectionState: AuthState = AuthStates.NOT_CONNECTED
165+
private _data: { startUrl: string; region: string } | undefined
166+
167+
private cancellationToken: CancellationTokenSource | undefined
168+
169+
constructor(
170+
public readonly profileName: string,
171+
private readonly lspAuth: LanguageClientAuth
172+
) {
173+
lspAuth.registerSsoTokenChangedHandler((params: SsoTokenChangedParams) => this.ssoTokenChangedHandler(params))
174+
}
175+
176+
get data() {
177+
return this._data
178+
}
179+
180+
async login(opts: { startUrl: string; region: string; scopes: string[] }) {
181+
await this.updateProfile(opts)
182+
return this._getSsoToken(true)
183+
}
184+
185+
async reauthenticate() {
186+
if (this.connectionState === AuthStates.NOT_CONNECTED) {
187+
throw new ToolkitError('Cannot reauthenticate when not connected.')
188+
}
189+
return this._getSsoToken(true)
190+
}
191+
192+
async logout() {
193+
if (this.ssoTokenId) {
194+
await this.lspAuth.invalidateSsoToken(this.ssoTokenId)
195+
}
196+
this.updateConnectionState(AuthStates.NOT_CONNECTED)
197+
this._data = undefined
198+
// TODO: DeleteProfile api in Identity Service (this doesn't exist yet)
199+
}
200+
201+
async updateProfile(opts: { startUrl: string; region: string; scopes: string[] }) {
202+
await this.lspAuth.updateProfile(this.profileName, opts.startUrl, opts.region, opts.scopes)
203+
this._data = {
204+
startUrl: opts.startUrl,
205+
region: opts.region,
206+
}
207+
}
208+
209+
/**
210+
* Restore the connection state and connection details to memory, if they exist.
211+
*/
212+
async restore() {
213+
const sessionData = await this.lspAuth.getProfile(this.profileName)
214+
const ssoSession = sessionData?.ssoSession?.settings
215+
if (ssoSession?.sso_region && ssoSession?.sso_start_url) {
216+
this._data = {
217+
startUrl: ssoSession.sso_start_url,
218+
region: ssoSession.sso_region,
219+
}
220+
}
221+
222+
try {
223+
await this._getSsoToken(false)
224+
} catch (err) {
225+
getLogger().error('Restoring connection failed: %s', err)
226+
}
227+
}
228+
229+
/**
230+
* Cancels running active login flows.
231+
*/
232+
cancelLogin() {
233+
this.cancellationToken?.cancel()
234+
this.cancellationToken?.dispose()
235+
this.cancellationToken = undefined
236+
}
237+
238+
/**
239+
* Returns a decrypted access token and a payload to send to the `updateCredentials` API provided by
240+
* the Amazon Q LSP.
241+
*/
242+
async getToken() {
243+
const response = await this._getSsoToken(false)
244+
const decryptedKey = await jose.compactDecrypt(response.ssoToken.accessToken, this.lspAuth.encryptionKey)
245+
return {
246+
token: decryptedKey.plaintext.toString().replaceAll('"', ''),
247+
updateCredentialsParams: response.updateCredentialsParams,
248+
}
249+
}
250+
251+
/**
252+
* Returns the response from `getToken` LSP API and sets the connection state based on the errors/result
253+
* of the call.
254+
*/
255+
private async _getSsoToken(login: boolean) {
256+
let response: GetSsoTokenResult
257+
this.cancellationToken = new CancellationTokenSource()
258+
259+
try {
260+
response = await this.lspAuth.getSsoToken(
261+
{
262+
/**
263+
* Note that we do not use SsoTokenSourceKind.AwsBuilderId here.
264+
* This is because it does not leave any state behind on disk, so
265+
* we cannot infer that a builder ID connection exists via the
266+
* Identity Server alone.
267+
*/
268+
kind: SsoTokenSourceKind.IamIdentityCenter,
269+
profileName: this.profileName,
270+
} satisfies IamIdentityCenterSsoTokenSource,
271+
login,
272+
this.cancellationToken.token
273+
)
274+
} catch (err: any) {
275+
switch (err.data?.awsErrorCode) {
276+
case AwsErrorCodes.E_CANCELLED:
277+
case AwsErrorCodes.E_SSO_SESSION_NOT_FOUND:
278+
case AwsErrorCodes.E_PROFILE_NOT_FOUND:
279+
case AwsErrorCodes.E_INVALID_SSO_TOKEN:
280+
this.updateConnectionState(AuthStates.NOT_CONNECTED)
281+
break
282+
case AwsErrorCodes.E_CANNOT_REFRESH_SSO_TOKEN:
283+
this.updateConnectionState(AuthStates.EXPIRED)
284+
break
285+
// TODO: implement when identity server emits E_NETWORK_ERROR, E_FILESYSTEM_ERROR
286+
// case AwsErrorCodes.E_NETWORK_ERROR:
287+
// case AwsErrorCodes.E_FILESYSTEM_ERROR:
288+
// // do stuff, probably nothing at all
289+
// break
290+
default:
291+
getLogger().error('SsoLogin: unknown error when requesting token: %s', err)
292+
break
293+
}
294+
throw err
295+
} finally {
296+
this.cancellationToken?.dispose()
297+
this.cancellationToken = undefined
298+
}
299+
300+
this.ssoTokenId = response.ssoToken.id
301+
this.updateConnectionState(AuthStates.CONNECTED)
302+
return response
303+
}
304+
305+
getConnectionState() {
306+
return this.connectionState
307+
}
308+
309+
onDidChangeConnectionState(handler: (e: AuthStateEvent) => any) {
310+
return this.eventEmitter.event(handler)
311+
}
312+
313+
private updateConnectionState(state: AuthState) {
314+
if (this.connectionState !== state) {
315+
this.eventEmitter.fire({ id: this.profileName, state })
316+
}
317+
this.connectionState = state
318+
}
319+
320+
private ssoTokenChangedHandler(params: SsoTokenChangedParams) {
321+
if (params.ssoTokenId === this.ssoTokenId) {
322+
if (params.kind === SsoTokenChangedKind.Expired) {
323+
this.updateConnectionState(AuthStates.EXPIRED)
324+
return
325+
} else if (params.kind === SsoTokenChangedKind.Refreshed) {
326+
this.eventEmitter.fire({ id: this.profileName, state: 'refreshed' })
327+
}
328+
}
329+
}
330+
}

packages/core/src/auth/sso/ssoAccessTokenProvider.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -289,17 +289,7 @@ export abstract class SsoAccessTokenProvider {
289289
profile: Pick<SsoProfile, 'startUrl' | 'region' | 'scopes' | 'identifier'>,
290290
cache = getCache(),
291291
oidc: OidcClient = OidcClient.create(profile.region),
292-
reAuthState?: ReAuthState,
293-
useDeviceFlow: () => boolean = () => {
294-
/**
295-
* Device code flow is neccessary when:
296-
* 1. We are in a workspace connected through ssh (codecatalyst, etc)
297-
* 2. We are connected to a remote backend through the web browser (code server, openshift dev spaces)
298-
*
299-
* Since we are unable to serve the final authorization page
300-
*/
301-
return getExtRuntimeContext().extensionHost === 'remote'
302-
}
292+
reAuthState?: ReAuthState
303293
) {
304294
if (DevSettings.instance.get('webAuth', false) && getExtRuntimeContext().extensionHost === 'webworker') {
305295
return new WebAuthorization(profile, cache, oidc, reAuthState)
@@ -400,6 +390,17 @@ function getSessionDuration(id: string) {
400390
return creationDate !== undefined ? globals.clock.Date.now() - creationDate : undefined
401391
}
402392

393+
export function useDeviceFlow(): boolean {
394+
/**
395+
* Device code flow is neccessary when:
396+
* 1. We are in a workspace connected through ssh (codecatalyst, etc)
397+
* 2. We are connected to a remote backend through the web browser (code server, openshift dev spaces)
398+
*
399+
* Since we are unable to serve the final authorization page
400+
*/
401+
return getExtRuntimeContext().extensionHost === 'remote'
402+
}
403+
403404
/**
404405
* SSO "device code" flow (RFC: https://tools.ietf.org/html/rfc8628)
405406
* 1. Get a client id (SSO-OIDC identifier, formatted per RFC6749).

0 commit comments

Comments
 (0)