Skip to content

Commit 8f0d76a

Browse files
authored
feat(amazonq): Migrate existing SSO connections to the LSP identity server (#7298)
## Problem Existing SSO connections on old auth, are not migrated automatically to the LSP Identity Server. This means that users would be signed out upon migration. ## Solution Add a migration functionality that checks for existing SSO connections, and migrate if necessary: * Check inside the `client.ts`, before we attempt to restore any connections in the new auth * Add `migrateSsoConnectionToLsp` method to `AuthUtil`, which: * Fetches environment `memento`, and looks for the `auth.profiles` key * Checks if there is an `sso` profile with `amazonQScopes`. If yes: migrate, if not: nothing to migrate * [migration]: Update the SSO profile with Flare * [migration]: Construct the existing (`from`) filenames and the Flare (`to`) filenames for the registration cache file and the SSO token cache file * [migration]: rename the files * [migration]: set the `auth.profiles` key to undefined, so migration is skipped next time ## Testing * Covered with unit tests that confirms the actual file system operations * Manual E2E testing, verifying that there is no sign-out happening with this setup * We will verify the mechanism through bug bashing --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent f849b3f commit 8f0d76a

File tree

6 files changed

+246
-6
lines changed

6 files changed

+246
-6
lines changed

packages/amazonq/src/lsp/client.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export async function startLanguageServer(
112112
await validateNodeExe(executable, resourcePaths.lsp, argv, logger)
113113

114114
// Options to control the language client
115+
const clientName = 'AmazonQ-For-VSCode'
115116
const clientOptions: LanguageClientOptions = {
116117
// Register the server for json documents
117118
documentSelector,
@@ -136,7 +137,7 @@ export async function startLanguageServer(
136137
name: env.appName,
137138
version: version,
138139
extension: {
139-
name: 'AmazonQ-For-VSCode',
140+
name: clientName,
140141
version: extensionVersion,
141142
},
142143
clientId: getClientId(globals.globalState),
@@ -193,6 +194,15 @@ export async function startLanguageServer(
193194
async function initializeAuth(client: LanguageClient) {
194195
AuthUtil.create(new auth2.LanguageClientAuth(client, clientId, encryptionKey))
195196

197+
// Migrate SSO connections from old Auth to the LSP identity server
198+
// This function only migrates connections once
199+
// This call can be removed once all/most users have updated to the latest AmazonQ version
200+
try {
201+
await AuthUtil.instance.migrateSsoConnectionToLsp(clientName)
202+
} catch (e) {
203+
getLogger().error(`Error while migration SSO connection to Amazon Q LSP: ${e}`)
204+
}
205+
196206
/** All must be setup before {@link AuthUtil.restore} otherwise they may not trigger when expected */
197207
AuthUtil.instance.regionProfileManager.onDidChangeRegionProfile(async () => {
198208
void pushConfigUpdate(client, {

packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
import assert from 'assert'
77
import * as sinon from 'sinon'
8-
import { AuthUtil } from 'aws-core-vscode/codewhisperer'
9-
import { createTestAuthUtil } from 'aws-core-vscode/test'
10-
import { constants } from 'aws-core-vscode/auth'
8+
import * as path from 'path'
9+
import { AuthUtil, amazonQScopes } from 'aws-core-vscode/codewhisperer'
10+
import { createTestAuthUtil, TestFolder } from 'aws-core-vscode/test'
11+
import { constants, cache } from 'aws-core-vscode/auth'
1112
import { auth2 } from 'aws-core-vscode/auth'
13+
import { mementoUtils, fs } from 'aws-core-vscode/shared'
1214

1315
describe('AuthUtil', async function () {
1416
let auth: any
@@ -193,4 +195,145 @@ describe('AuthUtil', async function () {
193195
assert.ok(clearCacheSpy.called)
194196
})
195197
})
198+
199+
describe('migrateSsoConnectionToLsp', function () {
200+
let memento: any
201+
let cacheDir: string
202+
let fromRegistrationFile: string
203+
let fromTokenFile: string
204+
205+
const validProfile = {
206+
type: 'sso',
207+
startUrl: 'https://test2.com',
208+
ssoRegion: 'us-east-1',
209+
scopes: amazonQScopes,
210+
metadata: {
211+
connectionState: 'valid',
212+
},
213+
}
214+
215+
beforeEach(async function () {
216+
memento = {
217+
get: sinon.stub(),
218+
update: sinon.stub().resolves(),
219+
}
220+
cacheDir = (await TestFolder.create()).path
221+
222+
sinon.stub(mementoUtils, 'getEnvironmentSpecificMemento').returns(memento)
223+
sinon.stub(cache, 'getCacheDir').returns(cacheDir)
224+
225+
fromTokenFile = cache.getTokenCacheFile(cacheDir, 'profile1')
226+
const registrationKey = {
227+
startUrl: validProfile.startUrl,
228+
region: validProfile.ssoRegion,
229+
scopes: amazonQScopes,
230+
}
231+
fromRegistrationFile = cache.getRegistrationCacheFile(cacheDir, registrationKey)
232+
233+
const registrationData = { test: 'registration' }
234+
const tokenData = { test: 'token' }
235+
236+
await fs.writeFile(fromRegistrationFile, JSON.stringify(registrationData))
237+
await fs.writeFile(fromTokenFile, JSON.stringify(tokenData))
238+
})
239+
240+
afterEach(async function () {
241+
sinon.restore()
242+
})
243+
244+
it('migrates valid SSO connection', async function () {
245+
memento.get.returns({ profile1: validProfile })
246+
247+
const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves()
248+
249+
await auth.migrateSsoConnectionToLsp('test-client')
250+
251+
assert.ok(updateProfileStub.calledOnce)
252+
assert.ok(memento.update.calledWith('auth.profiles', undefined))
253+
254+
const files = await fs.readdir(cacheDir)
255+
assert.strictEqual(files.length, 2) // Should have both the token and registration file
256+
257+
// Verify file contents were preserved
258+
const newFiles = files.map((f) => path.join(cacheDir, f[0]))
259+
for (const file of newFiles) {
260+
const content = await fs.readFileText(file)
261+
const parsed = JSON.parse(content)
262+
assert.ok(parsed.test === 'registration' || parsed.test === 'token')
263+
}
264+
})
265+
266+
it('does not migrate if no matching SSO profile exists', async function () {
267+
const mockProfiles = {
268+
'test-profile': {
269+
type: 'iam',
270+
startUrl: 'https://test.com',
271+
ssoRegion: 'us-east-1',
272+
},
273+
}
274+
memento.get.returns(mockProfiles)
275+
276+
await auth.migrateSsoConnectionToLsp('test-client')
277+
278+
// Assert that the file names have not updated
279+
const files = await fs.readdir(cacheDir)
280+
assert.ok(files.length === 2)
281+
assert.ok(await fs.exists(fromRegistrationFile))
282+
assert.ok(await fs.exists(fromTokenFile))
283+
assert.ok(!memento.update.called)
284+
})
285+
286+
it('migrates only profile with matching scopes', async function () {
287+
const mockProfiles = {
288+
profile1: validProfile,
289+
profile2: {
290+
type: 'sso',
291+
startUrl: 'https://test.com',
292+
ssoRegion: 'us-east-1',
293+
scopes: ['different:scope'],
294+
metadata: {
295+
connectionState: 'valid',
296+
},
297+
},
298+
}
299+
memento.get.returns(mockProfiles)
300+
301+
const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves()
302+
303+
await auth.migrateSsoConnectionToLsp('test-client')
304+
305+
assert.ok(updateProfileStub.calledOnce)
306+
assert.ok(memento.update.calledWith('auth.profiles', undefined))
307+
assert.deepStrictEqual(updateProfileStub.firstCall.args[0], {
308+
startUrl: validProfile.startUrl,
309+
region: validProfile.ssoRegion,
310+
scopes: validProfile.scopes,
311+
})
312+
})
313+
314+
it('uses valid connection state when multiple profiles exist', async function () {
315+
const mockProfiles = {
316+
profile2: {
317+
...validProfile,
318+
metadata: {
319+
connectionState: 'invalid',
320+
},
321+
},
322+
profile1: validProfile,
323+
}
324+
memento.get.returns(mockProfiles)
325+
326+
const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves()
327+
328+
await auth.migrateSsoConnectionToLsp('test-client')
329+
330+
assert.ok(
331+
updateProfileStub.calledWith({
332+
startUrl: validProfile.startUrl,
333+
region: validProfile.ssoRegion,
334+
scopes: validProfile.scopes,
335+
})
336+
)
337+
})
338+
})
196339
})

packages/core/src/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export { Auth } from './auth'
2323
export { CredentialsStore } from './credentials/store'
2424
export { LoginManager } from './deprecated/loginManager'
2525
export * as constants from './sso/constants'
26+
export * as cache from './sso/cache'
2627
export * as authUtils from './utils'
2728
export * as auth2 from './auth2'
2829
export * as SsoAccessTokenProvider from './sso/ssoAccessTokenProvider'

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export function getTokenCache(directory = getCacheDir()): KeyedCache<SsoAccess>
126126
return mapCache(cache, read, write)
127127
}
128128

129-
function getTokenCacheFile(ssoCacheDir: string, key: string): string {
129+
export function getTokenCacheFile(ssoCacheDir: string, key: string): string {
130130
const encoded = encodeURI(key)
131131
// Per the spec: 'SSO Login Token Flow' the access token must be
132132
// cached as the SHA1 hash of the bytes of the UTF-8 encoded
@@ -145,7 +145,7 @@ function getTokenCacheFile(ssoCacheDir: string, key: string): string {
145145
return path.join(ssoCacheDir, `${hashedKey}.json`)
146146
}
147147

148-
function getRegistrationCacheFile(ssoCacheDir: string, key: RegistrationKey): string {
148+
export function getRegistrationCacheFile(ssoCacheDir: string, key: RegistrationKey): string {
149149
const hash = (startUrl: string, scopes: string[]) => {
150150
const shasum = crypto.createHash('sha256')
151151
shasum.update(startUrl)

packages/core/src/codewhisperer/util/authUtil.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import * as vscode from 'vscode'
77
import * as localizedText from '../../shared/localizedText'
88
import * as nls from 'vscode-nls'
9+
import { fs } from '../../shared/fs/fs'
10+
import * as path from 'path'
11+
import * as crypto from 'crypto'
912
import { ToolkitError } from '../../shared/errors'
1013
import { AmazonQPromptSettings } from '../../shared/settings'
1114
import {
@@ -16,6 +19,9 @@ import {
1619
TelemetryMetadata,
1720
scopesSsoAccountAccess,
1821
hasScopes,
22+
SsoProfile,
23+
StoredProfile,
24+
hasExactScopes,
1925
} from '../../auth/connection'
2026
import { getLogger } from '../../shared/logger/logger'
2127
import { Commands } from '../../shared/vscode/commands2'
@@ -30,6 +36,8 @@ import { builderIdStartUrl, internalStartUrl } from '../../auth/sso/constants'
3036
import { VSCODE_EXTENSION_ID } from '../../shared/extensions'
3137
import { RegionProfileManager } from '../region/regionProfileManager'
3238
import { AuthFormId } from '../../login/webview/vue/types'
39+
import { getEnvironmentSpecificMemento } from '../../shared/utilities/mementos'
40+
import { getCacheDir, getRegistrationCacheFile, getTokenCacheFile } from '../../auth/sso/cache'
3341
import { notifySelectDeveloperProfile } from '../region/utils'
3442
import { once } from '../../shared/utilities/functionUtils'
3543

@@ -367,4 +375,81 @@ export class AuthUtil implements IAuthProvider {
367375

368376
return authIds
369377
}
378+
379+
/**
380+
* Migrates existing SSO connections to the LSP identity server by updating the cache files
381+
*
382+
* @param clientName - The client name to use for the new registration cache file
383+
* @returns A Promise that resolves when the migration is complete
384+
* @throws Error if file operations fail during migration
385+
*/
386+
async migrateSsoConnectionToLsp(clientName: string) {
387+
const memento = getEnvironmentSpecificMemento()
388+
const key = 'auth.profiles'
389+
const profiles: { readonly [id: string]: StoredProfile } | undefined = memento.get(key)
390+
391+
let toImport: SsoProfile | undefined
392+
let profileId: string | undefined
393+
394+
if (!profiles) {
395+
return
396+
} else {
397+
getLogger().info(`codewhisperer: checking for old SSO connections`)
398+
for (const [id, p] of Object.entries(profiles)) {
399+
if (p.type === 'sso' && hasExactScopes(p.scopes ?? [], amazonQScopes)) {
400+
toImport = p
401+
profileId = id
402+
if (p.metadata.connectionState === 'valid') {
403+
break
404+
}
405+
}
406+
}
407+
408+
if (toImport && profileId) {
409+
getLogger().info(`codewhisperer: migrating SSO connection to LSP identity server...`)
410+
411+
const registrationKey = {
412+
startUrl: toImport.startUrl,
413+
region: toImport.ssoRegion,
414+
scopes: amazonQScopes,
415+
}
416+
417+
await this.session.updateProfile(registrationKey)
418+
419+
const cacheDir = getCacheDir()
420+
421+
const hash = (str: string) => {
422+
const hasher = crypto.createHash('sha1')
423+
return hasher.update(str).digest('hex')
424+
}
425+
const filePath = (str: string) => {
426+
return path.join(cacheDir, hash(str) + '.json')
427+
}
428+
429+
const fromRegistrationFile = getRegistrationCacheFile(cacheDir, registrationKey)
430+
const toRegistrationFile = filePath(
431+
JSON.stringify({
432+
region: toImport.ssoRegion,
433+
startUrl: toImport.startUrl,
434+
tool: clientName,
435+
})
436+
)
437+
438+
const fromTokenFile = getTokenCacheFile(cacheDir, profileId)
439+
const toTokenFile = filePath(this.profileName)
440+
441+
try {
442+
await fs.rename(fromRegistrationFile, toRegistrationFile)
443+
await fs.rename(fromTokenFile, toTokenFile)
444+
getLogger().debug('Successfully renamed registration and token files')
445+
} catch (err) {
446+
getLogger().error(`Failed to rename files during migration: ${err}`)
447+
throw err
448+
}
449+
450+
await memento.update(key, undefined)
451+
getLogger().info(`codewhisperer: successfully migrated SSO connection to LSP identity server`)
452+
}
453+
}
454+
}
370455
}

packages/core/src/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,4 @@ export * as BaseLspInstaller from './lsp/baseLspInstaller'
7474
export * as collectionUtil from './utilities/collectionUtils'
7575
export * from './datetime'
7676
export * from './performance/marks'
77+
export * as mementoUtils from './utilities/mementos'

0 commit comments

Comments
 (0)