@@ -12,6 +12,104 @@ import * as vscode from 'vscode';
1212import { ext } from './extensionVariables' ;
1313import { inCloudShell } from './utils/inCloudShell' ;
1414
15+ /**
16+ * Thread-safe state manager for authentication accounts
17+ * @internal - Only exported for testing purposes
18+ */
19+ export class AuthAccountStateManager {
20+ private static instance : AuthAccountStateManager ;
21+ private accountsCache : readonly vscode . AuthenticationSessionAccountInformation [ ] = [ ] ;
22+ private isUpdating : boolean = false ;
23+ private pendingPromise : Promise < readonly vscode . AuthenticationSessionAccountInformation [ ] > | null = null ;
24+
25+ // eslint-disable-next-line @typescript-eslint/no-empty-function
26+ private constructor ( ) { }
27+
28+ public static getInstance ( ) : AuthAccountStateManager {
29+ if ( ! AuthAccountStateManager . instance ) {
30+ AuthAccountStateManager . instance = new AuthAccountStateManager ( ) ;
31+ }
32+ return AuthAccountStateManager . instance ;
33+ }
34+
35+ /**
36+ * Get accounts with thread-safe access. If an update is in progress, waits for it to complete.
37+ * Returns accounts along with change detection (new accounts added, accounts removed).
38+ */
39+ public async getAccounts ( authProviderId : string ) : Promise < {
40+ accounts : readonly vscode . AuthenticationSessionAccountInformation [ ] ;
41+ hasNewAccounts : boolean ;
42+ accountsRemoved : boolean ;
43+ } > {
44+ // If there's already a pending fetch, wait for it
45+ if ( this . pendingPromise ) {
46+ const accounts = await this . pendingPromise ;
47+ return { accounts, hasNewAccounts : false , accountsRemoved : false } ; // Already processed
48+ }
49+
50+ // If we're currently updating, create a promise that waits for the update to finish
51+ if ( this . isUpdating ) {
52+ const waitPromise = new Promise < readonly vscode . AuthenticationSessionAccountInformation [ ] > ( ( resolve , reject ) => {
53+ const timeoutMs = 10000 ; // 10 seconds timeout
54+ const checkInterval = setInterval ( ( ) => {
55+ if ( ! this . isUpdating ) {
56+ clearInterval ( checkInterval ) ;
57+ clearTimeout ( timeoutHandle ) ;
58+ resolve ( this . accountsCache ) ;
59+ }
60+ } , 100 ) ;
61+ const timeoutHandle = setTimeout ( ( ) => {
62+ clearInterval ( checkInterval ) ;
63+ reject ( new Error ( 'Timed out waiting for account update to finish.' ) ) ;
64+ } , timeoutMs ) ;
65+ } ) ;
66+ const accounts = await waitPromise ;
67+ return { accounts, hasNewAccounts : false , accountsRemoved : false } ; // Already processed
68+ }
69+
70+ // Fetch fresh accounts
71+ this . isUpdating = true ;
72+ const previousAccountIds = new Set ( this . accountsCache . map ( acc => acc . id ) ) ;
73+ const previousCount = this . accountsCache . length ;
74+
75+ this . pendingPromise = ( async ( ) => {
76+ try {
77+ const accounts = await vscode . authentication . getAccounts ( authProviderId ) ;
78+ this . accountsCache = accounts ;
79+ return accounts ;
80+ } finally {
81+ this . isUpdating = false ;
82+ this . pendingPromise = null ;
83+ }
84+ } ) ( ) ;
85+
86+ const accounts = await this . pendingPromise ;
87+
88+ // Check if there are any new accounts
89+ const hasNewAccounts = accounts . some ( acc => ! previousAccountIds . has ( acc . id ) ) ;
90+
91+ // Check if any accounts were removed (sign-out event)
92+ // Either count decreased or some previous account IDs are no longer present
93+ const accountsRemoved = accounts . length < previousCount ;
94+
95+ return { accounts, hasNewAccounts, accountsRemoved } ;
96+ }
97+
98+ /**
99+ * Get cached accounts without fetching. Returns empty array if not yet fetched.
100+ */
101+ public getCachedAccounts ( ) : readonly vscode . AuthenticationSessionAccountInformation [ ] {
102+ return [ ...this . accountsCache ] ;
103+ }
104+
105+ /**
106+ * Clear the cached accounts state
107+ */
108+ public clearCache ( ) : void {
109+ this . accountsCache = [ ] ;
110+ }
111+ }
112+
15113const AUTH_RECORD_README = `
16114The \`authRecord.json\` file is created after authenticating to an Azure subscription from Visual Studio Code (VS Code). For example, via the **Azure: Sign In** command in Command Palette. The directory in which the file resides matches the unique identifier of the [Azure Resources extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azureresourcegroups) responsible for writing the file.
17115
@@ -61,6 +159,15 @@ export function registerExportAuthRecordOnSessionChange(_context: ExtensionConte
61159 } ) ;
62160}
63161
162+ /**
163+ * Get the singleton instance of AuthAccountStateManager for managing authentication accounts state.
164+ * This provides thread-safe access to accounts fetched during auth record persistence.
165+ * @internal - Only exported for testing purposes
166+ */
167+ export function getAuthAccountStateManager ( ) : AuthAccountStateManager {
168+ return AuthAccountStateManager . getInstance ( ) ;
169+ }
170+
64171/**
65172 * Exports the current authentication record to a well-known location in the user's .azure directory.
66173 * Used for interoperability with other tools and applications.
@@ -80,12 +187,56 @@ export async function exportAuthRecord(context: IActionContext, evt?: vscode.Aut
80187 }
81188
82189 try {
190+ // Get accounts and check for changes (new accounts added or accounts removed)
191+ const accountStateManager = AuthAccountStateManager . getInstance ( ) ;
192+ const { accounts : allAccounts , hasNewAccounts, accountsRemoved } = await accountStateManager . getAccounts ( AUTH_PROVIDER_ID ) ;
193+
194+ // Scenario 1: No accounts exist at all (all signed out)
195+ if ( allAccounts . length === 0 ) {
196+ await cleanupAuthRecordIfPresent ( ) ;
197+ return ;
198+ }
199+
200+ // Scenario 2: Accounts were removed (sign-out event) but some remain
201+ if ( accountsRemoved && allAccounts . length > 0 ) {
202+ // Fetch session for one of the remaining accounts and export its auth record
203+ const session = await getAuthenticationSession ( AUTH_PROVIDER_ID , SCOPES ) ;
83204
205+ if ( ! session ) {
206+ // No valid session for remaining accounts
207+ return ;
208+ }
209+
210+ // Get tenantId from idToken or config override
211+ const tenantId = getTenantId ( session , context ) ;
212+
213+ // AuthenticationRecord structure for the remaining account
214+ const authRecord = {
215+ username : session . account . label ,
216+ authority : 'https://login.microsoftonline.com' , // VS Code auth provider default
217+ homeAccountId : `${ session . account . id } ` ,
218+ tenantId,
219+ // This is the public client ID used by VS Code for Microsoft authentication.
220+ // See: https://github.com/microsoft/vscode/blob/973a531c70579b7a51544f32931fdafd32de285e/extensions/microsoft-authentication/src/AADHelper.ts#L21
221+ clientId : 'aebc6443-996d-45c2-90f0-388ff96faa56' ,
222+ datetime : new Date ( ) . toISOString ( ) // Current UTC time in ISO8601 format
223+ } ;
224+
225+ // Export the auth record to the user's .azure directory
226+ await persistAuthRecord ( authRecord ) ;
227+ return ;
228+ }
229+
230+ // Scenario 3: No new accounts and no removals (e.g., token refresh)
231+ if ( ! hasNewAccounts && ! accountsRemoved ) {
232+ return ;
233+ }
234+
235+ // Scenario 4: New account detected - fetch session and export auth record
84236 const session = await getAuthenticationSession ( AUTH_PROVIDER_ID , SCOPES ) ;
85237
86238 if ( ! session ) {
87- // If no session is found, clean up any existing auth record and exit
88- await cleanupAuthRecordIfPresent ( ) ;
239+ // Session could not be retrieved despite new accounts
89240 return ;
90241 }
91242
@@ -207,14 +358,19 @@ async function cleanupAuthRecordIfPresent(): Promise<void> {
207358 if ( await fs . pathExists ( authRecordPath ) ) {
208359 await fs . remove ( authRecordPath ) ;
209360 }
361+
362+ // Clear the cached accounts state when cleaning up
363+ AuthAccountStateManager . getInstance ( ) . clearCache ( ) ;
210364}
211365
212366// Helper to get the authentication session for the given auth provider and scopes
367+ // This should only be called when we know there are accounts
213368async function getAuthenticationSession (
214369 authProviderId : string ,
215370 scopes : string [ ]
216371) : Promise < vscode . AuthenticationSession | undefined > {
217- const allAccounts = await vscode . authentication . getAccounts ( authProviderId ) ;
372+ const accountStateManager = AuthAccountStateManager . getInstance ( ) ;
373+ const cachedAccounts = accountStateManager . getCachedAccounts ( ) ;
218374
219375 // Try to get the current authentication session silently.
220376 let session = await vscode . authentication . getSession (
@@ -225,16 +381,16 @@ async function getAuthenticationSession(
225381
226382 if ( session ) {
227383 // Ensure session represents the active accounts. (i.e. not a user being logged out.)
228- const isLoggedIn = allAccounts . some ( account => account . id === session ?. account . id ) ;
384+ const isLoggedIn = cachedAccounts . some ( account => account . id === session ?. account . id ) ;
229385 if ( ! isLoggedIn ) {
230386 session = undefined ; // Reset session if it doesn't match any active account, as it represents a user being logged out.
231387 }
232388 }
233389
234- if ( ! session && allAccounts . length > 0 ) {
390+ if ( ! session && cachedAccounts . length > 0 ) {
235391 // no active session found, but accounts exist
236392 // Get the first available session for the active accounts.
237- for ( const account of allAccounts ) {
393+ for ( const account of cachedAccounts ) {
238394 session = await vscode . authentication . getSession (
239395 authProviderId ,
240396 scopes ,
0 commit comments