Skip to content

Commit f7a63f3

Browse files
authored
Optimize auth record export. (#1267)
1 parent df679c7 commit f7a63f3

File tree

3 files changed

+328
-6
lines changed

3 files changed

+328
-6
lines changed

extension.bundle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export * from './src/services/AzureResourcesService';
3333
export { createResourceGroup } from './src/commands/createResourceGroup';
3434
export * from './src/commands/deleteResourceGroup/v2/deleteResourceGroupV2';
3535
export { activate, deactivate } from './src/extension';
36+
// Export for testing only - not part of public API
37+
export { AuthAccountStateManager, getAuthAccountStateManager } from './src/exportAuthRecord';
3638
export * from './src/extensionVariables';
3739
export * from './src/hostapi.v2.internal';
3840
export * from './src/tree/azure/AzureResourceItem';

src/exportAuthRecord.ts

Lines changed: 162 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,104 @@ import * as vscode from 'vscode';
1212
import { ext } from './extensionVariables';
1313
import { 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+
15113
const AUTH_RECORD_README = `
16114
The \`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
213368
async 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,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
7+
8+
import * as assert from 'assert';
9+
import { AuthAccountStateManager, getAuthAccountStateManager } from '../extension.bundle';
10+
11+
suite('AuthAccountStateManager Tests', () => {
12+
let stateManager: AuthAccountStateManager;
13+
14+
setup(() => {
15+
stateManager = getAuthAccountStateManager();
16+
// Clear cache before each test
17+
stateManager.clearCache();
18+
});
19+
20+
teardown(() => {
21+
// Clean up after each test
22+
stateManager.clearCache();
23+
});
24+
25+
test('getInstance returns singleton instance', () => {
26+
const instance1 = getAuthAccountStateManager();
27+
const instance2 = getAuthAccountStateManager();
28+
assert.strictEqual(instance1, instance2, 'Should return the same singleton instance');
29+
});
30+
31+
test('getCachedAccounts returns empty array initially', () => {
32+
const cachedAccounts = stateManager.getCachedAccounts();
33+
assert.strictEqual(cachedAccounts.length, 0, 'Should return empty array initially');
34+
});
35+
36+
test('clearCache resets the cached accounts', async () => {
37+
// First, fetch accounts to populate cache
38+
await stateManager.getAccounts('microsoft');
39+
40+
// Verify cache is populated (may be empty if no accounts, but should not throw)
41+
const cachedBefore = stateManager.getCachedAccounts();
42+
assert.ok(Array.isArray(cachedBefore), 'Cached accounts should be an array');
43+
44+
// Clear cache
45+
stateManager.clearCache();
46+
47+
// Verify cache is empty
48+
const cachedAfter = stateManager.getCachedAccounts();
49+
assert.strictEqual(cachedAfter.length, 0, 'Should return empty array after clearing cache');
50+
});
51+
52+
test('getAccounts returns accounts with change detection flags', async () => {
53+
const result = await stateManager.getAccounts('microsoft');
54+
55+
assert.ok(result, 'Result should be defined');
56+
assert.ok(Array.isArray(result.accounts), 'accounts should be an array');
57+
assert.strictEqual(typeof result.hasNewAccounts, 'boolean', 'hasNewAccounts should be a boolean');
58+
assert.strictEqual(typeof result.accountsRemoved, 'boolean', 'accountsRemoved should be a boolean');
59+
});
60+
61+
test('getAccounts caches accounts after first fetch', async () => {
62+
// First fetch
63+
const firstResult = await stateManager.getAccounts('microsoft');
64+
const firstCached = stateManager.getCachedAccounts();
65+
66+
// Verify cache is populated
67+
assert.strictEqual(firstCached.length, firstResult.accounts.length, 'Cache should match fetched accounts');
68+
69+
// Verify cached accounts match fetched accounts
70+
for (let i = 0; i < firstCached.length; i++) {
71+
assert.strictEqual(firstCached[i].id, firstResult.accounts[i].id, 'Cached account IDs should match');
72+
assert.strictEqual(firstCached[i].label, firstResult.accounts[i].label, 'Cached account labels should match');
73+
}
74+
});
75+
76+
test('getCachedAccounts returns a copy of the cache', () => {
77+
const cached1 = stateManager.getCachedAccounts();
78+
const cached2 = stateManager.getCachedAccounts();
79+
80+
// Both should be arrays
81+
assert.ok(Array.isArray(cached1), 'Should return an array');
82+
assert.ok(Array.isArray(cached2), 'Should return an array');
83+
84+
// They should have the same content but not be the same reference
85+
assert.notStrictEqual(cached1, cached2, 'Should return different array instances');
86+
});
87+
88+
test('getAccounts handles concurrent calls gracefully', async () => {
89+
// Make multiple concurrent calls
90+
const promises = [
91+
stateManager.getAccounts('microsoft'),
92+
stateManager.getAccounts('microsoft'),
93+
stateManager.getAccounts('microsoft')
94+
];
95+
96+
const results = await Promise.all(promises);
97+
98+
// All results should be defined
99+
results.forEach((result: Awaited<ReturnType<typeof stateManager.getAccounts>>, index: number) => {
100+
assert.ok(result, `Result ${index} should be defined`);
101+
assert.ok(Array.isArray(result.accounts), `Result ${index} accounts should be an array`);
102+
});
103+
104+
// All results should have the same account IDs
105+
const firstAccountIds = results[0].accounts.map((acc: { id: string }) => acc.id).sort();
106+
results.forEach((result: Awaited<ReturnType<typeof stateManager.getAccounts>>, index: number) => {
107+
const accountIds = result.accounts.map((acc: { id: string }) => acc.id).sort();
108+
assert.deepStrictEqual(accountIds, firstAccountIds, `Result ${index} should have the same account IDs`);
109+
});
110+
});
111+
112+
test('hasNewAccounts is true on first fetch with accounts', async function () {
113+
this.timeout(10000); // Increase timeout for authentication checks
114+
115+
// Clear cache to ensure fresh state
116+
stateManager.clearCache();
117+
118+
// First fetch
119+
const result = await stateManager.getAccounts('microsoft');
120+
121+
// If there are accounts, hasNewAccounts should be true on first fetch
122+
if (result.accounts.length > 0) {
123+
assert.strictEqual(result.hasNewAccounts, true, 'Should detect new accounts on first fetch when accounts exist');
124+
} else {
125+
// If no accounts exist, hasNewAccounts should be false
126+
assert.strictEqual(result.hasNewAccounts, false, 'Should not detect new accounts when no accounts exist');
127+
}
128+
});
129+
130+
test('hasNewAccounts is false on subsequent fetch with same accounts', async function () {
131+
this.timeout(10000); // Increase timeout for authentication checks
132+
133+
// First fetch to populate cache
134+
await stateManager.getAccounts('microsoft');
135+
136+
// Second fetch should not detect new accounts (assuming accounts haven't changed)
137+
const result = await stateManager.getAccounts('microsoft');
138+
139+
assert.strictEqual(result.hasNewAccounts, false, 'Should not detect new accounts on subsequent fetch');
140+
});
141+
142+
test('accountsRemoved is false when no accounts are removed', async function () {
143+
this.timeout(10000); // Increase timeout for authentication checks
144+
145+
// First fetch to populate cache
146+
await stateManager.getAccounts('microsoft');
147+
148+
// Second fetch should not detect removed accounts (assuming accounts haven't changed)
149+
const result = await stateManager.getAccounts('microsoft');
150+
151+
assert.strictEqual(result.accountsRemoved, false, 'Should not detect removed accounts when accounts remain the same');
152+
});
153+
154+
test('multiple calls to clearCache are safe', () => {
155+
// Clear multiple times
156+
stateManager.clearCache();
157+
stateManager.clearCache();
158+
stateManager.clearCache();
159+
160+
// Should still return empty array
161+
const cached = stateManager.getCachedAccounts();
162+
assert.strictEqual(cached.length, 0, 'Should return empty array after multiple clears');
163+
});
164+
});

0 commit comments

Comments
 (0)