From 66a06aa9b34305c2109104ad06d95d31c732b701 Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 11:16:00 +0200 Subject: [PATCH 01/10] Add azure tenant setting for sign-in --- package.json | 6 +++ package.nls.json | 1 + src/commands/accounts/logIn.ts | 5 +- src/exportAuthRecord.ts | 16 ++----- src/utils/azureTenantSetting.ts | 26 +++++++++++ test/azureTenantSetting.test.ts | 81 +++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 src/utils/azureTenantSetting.ts create mode 100644 test/azureTenantSetting.test.ts diff --git a/package.json b/package.json index 6deb59832..a16cdc967 100644 --- a/package.json +++ b/package.json @@ -723,6 +723,12 @@ { "title": "Azure", "properties": { + "azure.tenant": { + "type": "string", + "description": "%azure.tenant%", + "default": "", + "scope": "machine" + }, "azureResourceGroups.enableOutputTimestamps": { "type": "boolean", "description": "%azureResourceGroups.enableOutputTimestamps%", diff --git a/package.nls.json b/package.nls.json index 9d67654dd..91e0604e7 100644 --- a/package.nls.json +++ b/package.nls.json @@ -8,6 +8,7 @@ "azureResourceGroups.selectSubscriptions": "Select Subscriptions...", "azureResourceGroups.createResourceGroup": "Create Resource Group...", "azureResourceGroups.createResourceGroupDetail": "The logical grouping for your resources.", + "azure.tenant": "The Microsoft Entra tenant ID or domain to use when signing in to Azure.", "azureResourceGroups.deleteResourceGroup": "Delete Resource Group...", "azureResourceGroups.editTags": "Edit Tags...", "azureResourceGroups.description": "An extension for viewing and managing Azure resources.", diff --git a/src/commands/accounts/logIn.ts b/src/commands/accounts/logIn.ts index fbe68a413..3dc8c7fec 100644 --- a/src/commands/accounts/logIn.ts +++ b/src/commands/accounts/logIn.ts @@ -6,6 +6,7 @@ import { IActionContext } from '@microsoft/vscode-azext-utils'; import type { SignInOptions } from '@microsoft/vscode-azext-azureauth'; import { ext } from '../../extensionVariables'; +import { getConfiguredAzureTenant } from '../../utils/azureTenantSetting'; // eslint-disable-next-line @typescript-eslint/naming-convention let _isLoggingIn: boolean = false; @@ -16,7 +17,9 @@ export async function logIn(_context: IActionContext, options?: SignInOptions): _isLoggingIn = true; ext.actions.refreshAzureTree(); // Refresh to cause the "logging in" spinner to show ext.actions.refreshTenantTree(); // Refresh to cause the "logging in" spinner to show - await provider.signIn(undefined, options); + const tenantId = getConfiguredAzureTenant(); + const tenant = tenantId ? { tenantId } as Parameters[0] : undefined; + await provider.signIn(tenant, options); } finally { _isLoggingIn = false; // Clear cache to ensure fresh data is fetched after sign-in diff --git a/src/exportAuthRecord.ts b/src/exportAuthRecord.ts index 9941ba6ec..0a53a8320 100644 --- a/src/exportAuthRecord.ts +++ b/src/exportAuthRecord.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import type { ExtensionContext } from 'vscode'; import * as vscode from 'vscode'; import { ext } from './extensionVariables'; +import { addConfiguredTenantScope, getConfiguredAzureTenant } from './utils/azureTenantSetting'; import { inCloudShell } from './utils/inCloudShell'; /** @@ -174,7 +175,7 @@ export function getAuthAccountStateManager(): AuthAccountStateManager { */ export async function exportAuthRecord(context: IActionContext, evt?: vscode.AuthenticationSessionsChangeEvent): Promise { const AUTH_PROVIDER_ID = 'microsoft'; // VS Code Azure auth provider - const SCOPES = ['https://management.azure.com/.default']; // Default ARM scope + const SCOPES = addConfiguredTenantScope(['https://management.azure.com/.default']); // Default ARM scope, optionally tenant-scoped context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; @@ -264,16 +265,9 @@ export async function exportAuthRecord(context: IActionContext, evt?: vscode.Aut // Helper to get tenantId from session or config override function getTenantId(session: unknown, context?: IActionContext): string | undefined { - let tenantFromArg: string | undefined = undefined; - try { - // This handles the case if an error is thrown, if the configuration is not registered by any extension - tenantFromArg = vscode.workspace.getConfiguration().get('@azure.argTenant'); - } catch { - // If the configuration is not found, ignore and proceed - ext.outputChannel.appendLine('No @azure.argTenant configuration found. Proceeding without tenant override.'); - } - if (tenantFromArg) { - return tenantFromArg; + const configuredTenant = getConfiguredAzureTenant(); + if (configuredTenant) { + return configuredTenant; } return extractTenantIdFromIdToken(session, context); } diff --git a/src/utils/azureTenantSetting.ts b/src/utils/azureTenantSetting.ts new file mode 100644 index 000000000..99388d7c0 --- /dev/null +++ b/src/utils/azureTenantSetting.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export const azureTenantSettingKey = 'azure.tenant'; + +export function normalizeConfiguredTenant(tenant: string | undefined): string | undefined { + const trimmedTenant = tenant?.trim(); + return trimmedTenant ? trimmedTenant : undefined; +} + +export function getConfiguredAzureTenant(): string | undefined { + return normalizeConfiguredTenant(vscode.workspace.getConfiguration().get(azureTenantSettingKey)); +} + +export function addConfiguredTenantScope(scopes: readonly string[], tenant: string | undefined = getConfiguredAzureTenant()): string[] { + const normalizedTenant = normalizeConfiguredTenant(tenant); + if (!normalizedTenant) { + return [...scopes]; + } + + return Array.from(new Set([...scopes, `VSCODE_TENANT:${normalizedTenant}`])); +} diff --git a/test/azureTenantSetting.test.ts b/test/azureTenantSetting.test.ts new file mode 100644 index 000000000..6b574d4cb --- /dev/null +++ b/test/azureTenantSetting.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import type { AzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import * as vscode from 'vscode'; +import { logIn } from '../src/commands/accounts/logIn'; +import { ext } from '../src/extensionVariables'; +import { addConfiguredTenantScope, normalizeConfiguredTenant } from '../src/utils/azureTenantSetting'; + +suite('azureTenantSetting', () => { + test('normalizeConfiguredTenant returns undefined for blank tenants', () => { + assert.strictEqual(normalizeConfiguredTenant(undefined), undefined); + assert.strictEqual(normalizeConfiguredTenant(''), undefined); + assert.strictEqual(normalizeConfiguredTenant(' '), undefined); + }); + + test('normalizeConfiguredTenant trims configured tenants', () => { + assert.strictEqual(normalizeConfiguredTenant(' contoso.onmicrosoft.com '), 'contoso.onmicrosoft.com'); + }); + + test('addConfiguredTenantScope appends VSCODE_TENANT scope when tenant is configured', () => { + assert.deepStrictEqual( + addConfiguredTenantScope(['https://management.azure.com/.default'], 'contoso.onmicrosoft.com'), + ['https://management.azure.com/.default', 'VSCODE_TENANT:contoso.onmicrosoft.com'] + ); + }); + + test('addConfiguredTenantScope leaves scopes unchanged when tenant is not configured', () => { + assert.deepStrictEqual( + addConfiguredTenantScope(['https://management.azure.com/.default'], ' '), + ['https://management.azure.com/.default'] + ); + }); + + test('addConfiguredTenantScope does not duplicate tenant scope', () => { + assert.deepStrictEqual( + addConfiguredTenantScope( + ['https://management.azure.com/.default', 'VSCODE_TENANT:contoso.onmicrosoft.com'], + 'contoso.onmicrosoft.com' + ), + ['https://management.azure.com/.default', 'VSCODE_TENANT:contoso.onmicrosoft.com'] + ); + }); + + test('logIn passes configured azure.tenant to subscription provider signIn', async () => { + const originalSubscriptionProviderFactory = ext.subscriptionProviderFactory; + const originalRefreshAzureTree = ext.actions.refreshAzureTree; + const originalRefreshTenantTree = ext.actions.refreshTenantTree; + const originalRefreshFocusTree = ext.actions.refreshFocusTree; + + let signedInTenantId: string | undefined; + const provider = { + signIn: async (tenant?: { tenantId: string }): Promise => { + signedInTenantId = tenant?.tenantId; + return true; + }, + } as AzureSubscriptionProvider; + + try { + ext.subscriptionProviderFactory = async () => provider; + ext.actions.refreshAzureTree = () => { /* no-op */ }; + ext.actions.refreshTenantTree = () => { /* no-op */ }; + ext.actions.refreshFocusTree = () => { /* no-op */ }; + + await vscode.workspace.getConfiguration().update('azure.tenant', 'contoso.onmicrosoft.com', vscode.ConfigurationTarget.Global); + + await logIn({} as never); + + assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); + } finally { + await vscode.workspace.getConfiguration().update('azure.tenant', undefined, vscode.ConfigurationTarget.Global); + ext.subscriptionProviderFactory = originalSubscriptionProviderFactory; + ext.actions.refreshAzureTree = originalRefreshAzureTree; + ext.actions.refreshTenantTree = originalRefreshTenantTree; + ext.actions.refreshFocusTree = originalRefreshFocusTree; + } + }); +}); From 451dc18a062c9f3fd4eb9c375755220b49c73e9f Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 11:48:12 +0200 Subject: [PATCH 02/10] Use configured tenant for provider authentication --- .vscodeignore | 2 ++ .../VSCodeAzureSubscriptionProvider.ts | 36 +++++++++++++------ src/utils/azureTenantSetting.ts | 4 +++ test/azureTenantSetting.test.ts | 10 +++++- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index d328b21be..a3d200582 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -4,6 +4,7 @@ .github/** .gitignore .vscode-test/** +.playwright-mcp/** .vscode/** *.tgz **/*.gif @@ -17,6 +18,7 @@ node_modules/** out/** resources/readme/** resources/changelog/** +pr-assets/** src/** stats.json test-results.xml diff --git a/src/services/VSCodeAzureSubscriptionProvider.ts b/src/services/VSCodeAzureSubscriptionProvider.ts index 71f09e15f..0de949687 100644 --- a/src/services/VSCodeAzureSubscriptionProvider.ts +++ b/src/services/VSCodeAzureSubscriptionProvider.ts @@ -3,19 +3,26 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; -import { ext } from '../extensionVariables'; -import { isTenantFilteredOut } from '../utils/tenantSelection'; +import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, SignInOptions, TenantIdAndAccount, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; +import { ext } from '../extensionVariables'; +import { getTenantIdForAuthentication } from '../utils/azureTenantSetting'; +import { isTenantFilteredOut } from '../utils/tenantSelection'; /** * Extends {@link VSCodeAzureSubscriptionProvider} to additionally filter tenants based on the * Accounts & Tenants view checkbox state. Without this, unselected tenants still count toward * the maximum tenants limit, preventing selected tenants from being processed. */ -class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider { - public override async getTenantsForAccount(account: AzureAccount, options?: GetTenantsForAccountOptions): Promise { - const tenants = await super.getTenantsForAccount(account, options); +class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider { + public override async signIn(tenant?: TenantIdAndAccount, options?: SignInOptions): Promise { + const tenantId = getTenantIdForAuthentication(tenant?.tenantId); + const tenantForSignIn = tenantId ? { ...tenant, tenantId } as TenantIdAndAccount : tenant; + return super.signIn(tenantForSignIn, options); + } + + public override async getTenantsForAccount(account: AzureAccount, options?: GetTenantsForAccountOptions): Promise { + const tenants = await super.getTenantsForAccount(account, options); // When filtering is enabled, also exclude tenants unchecked in the Accounts & Tenants view. // The base class only filters by selectedSubscriptions config; the Tenants view stores its @@ -28,10 +35,17 @@ class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider } return filtered; } - - return tenants; - } -} + + return tenants; + } + + protected override getSubscriptionContext(tenant: Partial) { + return super.getSubscriptionContext({ + ...tenant, + tenantId: getTenantIdForAuthentication(tenant.tenantId), + }); + } +} let vscodeAzureSubscriptionProvider: VSCodeAzureSubscriptionProvider | undefined; diff --git a/src/utils/azureTenantSetting.ts b/src/utils/azureTenantSetting.ts index 99388d7c0..2bae2994e 100644 --- a/src/utils/azureTenantSetting.ts +++ b/src/utils/azureTenantSetting.ts @@ -24,3 +24,7 @@ export function addConfiguredTenantScope(scopes: readonly string[], tenant: stri return Array.from(new Set([...scopes, `VSCODE_TENANT:${normalizedTenant}`])); } + +export function getTenantIdForAuthentication(tenantId: string | undefined, configuredTenant: string | undefined = getConfiguredAzureTenant()): string | undefined { + return normalizeConfiguredTenant(tenantId) ?? normalizeConfiguredTenant(configuredTenant); +} diff --git a/test/azureTenantSetting.test.ts b/test/azureTenantSetting.test.ts index 6b574d4cb..f933bc380 100644 --- a/test/azureTenantSetting.test.ts +++ b/test/azureTenantSetting.test.ts @@ -8,7 +8,7 @@ import type { AzureSubscriptionProvider } from '@microsoft/vscode-azext-azureaut import * as vscode from 'vscode'; import { logIn } from '../src/commands/accounts/logIn'; import { ext } from '../src/extensionVariables'; -import { addConfiguredTenantScope, normalizeConfiguredTenant } from '../src/utils/azureTenantSetting'; +import { addConfiguredTenantScope, getTenantIdForAuthentication, normalizeConfiguredTenant } from '../src/utils/azureTenantSetting'; suite('azureTenantSetting', () => { test('normalizeConfiguredTenant returns undefined for blank tenants', () => { @@ -45,6 +45,14 @@ suite('azureTenantSetting', () => { ); }); + test('getTenantIdForAuthentication prefers explicit tenant over configured tenant', () => { + assert.strictEqual(getTenantIdForAuthentication('subscription-tenant', 'configured-tenant'), 'subscription-tenant'); + }); + + test('getTenantIdForAuthentication falls back to configured tenant', () => { + assert.strictEqual(getTenantIdForAuthentication(undefined, 'configured-tenant'), 'configured-tenant'); + }); + test('logIn passes configured azure.tenant to subscription provider signIn', async () => { const originalSubscriptionProviderFactory = ext.subscriptionProviderFactory; const originalRefreshAzureTree = ext.actions.refreshAzureTree; From 9567f03b7bbf72e05790bc8e4fd7614d7d23d468 Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 11:57:41 +0200 Subject: [PATCH 03/10] Remove local asset ignore entries --- .vscodeignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index a3d200582..d328b21be 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -4,7 +4,6 @@ .github/** .gitignore .vscode-test/** -.playwright-mcp/** .vscode/** *.tgz **/*.gif @@ -18,7 +17,6 @@ node_modules/** out/** resources/readme/** resources/changelog/** -pr-assets/** src/** stats.json test-results.xml From 7d46ce58dee1cbc6902d05d7d4e5ee394586cd59 Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 12:23:57 +0200 Subject: [PATCH 04/10] Address tenant auth review feedback --- src/exportAuthRecord.ts | 8 ++------ src/services/VSCodeAzureSubscriptionProvider.ts | 6 +++++- test/azureTenantSetting.test.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/exportAuthRecord.ts b/src/exportAuthRecord.ts index 0a53a8320..64a1bc129 100644 --- a/src/exportAuthRecord.ts +++ b/src/exportAuthRecord.ts @@ -264,12 +264,8 @@ export async function exportAuthRecord(context: IActionContext, evt?: vscode.Aut } // Helper to get tenantId from session or config override -function getTenantId(session: unknown, context?: IActionContext): string | undefined { - const configuredTenant = getConfiguredAzureTenant(); - if (configuredTenant) { - return configuredTenant; - } - return extractTenantIdFromIdToken(session, context); +export function getTenantId(session: unknown, context?: IActionContext): string | undefined { + return extractTenantIdFromIdToken(session, context) ?? getConfiguredAzureTenant(); } // Helper to extract tenantId (tid) from the idToken of a VS Code authentication session (For MS Auth, this will be present). diff --git a/src/services/VSCodeAzureSubscriptionProvider.ts b/src/services/VSCodeAzureSubscriptionProvider.ts index 0de949687..9a2a979bc 100644 --- a/src/services/VSCodeAzureSubscriptionProvider.ts +++ b/src/services/VSCodeAzureSubscriptionProvider.ts @@ -16,7 +16,11 @@ import { isTenantFilteredOut } from '../utils/tenantSelection'; */ class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider { public override async signIn(tenant?: TenantIdAndAccount, options?: SignInOptions): Promise { - const tenantId = getTenantIdForAuthentication(tenant?.tenantId); + if (tenant?.tenantId) { + return super.signIn(tenant, options); + } + + const tenantId = getTenantIdForAuthentication(undefined); const tenantForSignIn = tenantId ? { ...tenant, tenantId } as TenantIdAndAccount : tenant; return super.signIn(tenantForSignIn, options); } diff --git a/test/azureTenantSetting.test.ts b/test/azureTenantSetting.test.ts index f933bc380..4a5756997 100644 --- a/test/azureTenantSetting.test.ts +++ b/test/azureTenantSetting.test.ts @@ -8,6 +8,7 @@ import type { AzureSubscriptionProvider } from '@microsoft/vscode-azext-azureaut import * as vscode from 'vscode'; import { logIn } from '../src/commands/accounts/logIn'; import { ext } from '../src/extensionVariables'; +import { getTenantId } from '../src/exportAuthRecord'; import { addConfiguredTenantScope, getTenantIdForAuthentication, normalizeConfiguredTenant } from '../src/utils/azureTenantSetting'; suite('azureTenantSetting', () => { @@ -53,6 +54,20 @@ suite('azureTenantSetting', () => { assert.strictEqual(getTenantIdForAuthentication(undefined, 'configured-tenant'), 'configured-tenant'); }); + test('getTenantId prefers token tid over configured tenant domain', async () => { + const tenantId = '11111111-2222-3333-4444-555555555555'; + const tokenPayload = Buffer.from(JSON.stringify({ tid: tenantId })).toString('base64'); + const session = { idToken: `header.${tokenPayload}.signature` }; + + try { + await vscode.workspace.getConfiguration().update('azure.tenant', 'contoso.onmicrosoft.com', vscode.ConfigurationTarget.Global); + + assert.strictEqual(getTenantId(session), tenantId); + } finally { + await vscode.workspace.getConfiguration().update('azure.tenant', undefined, vscode.ConfigurationTarget.Global); + } + }); + test('logIn passes configured azure.tenant to subscription provider signIn', async () => { const originalSubscriptionProviderFactory = ext.subscriptionProviderFactory; const originalRefreshAzureTree = ext.actions.refreshAzureTree; From 144eebe996d8f9a70d8c5c357895bfc47ea1658e Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 16:17:04 +0200 Subject: [PATCH 05/10] Add manual tenant sign-in fallback --- package.json | 6 - package.nls.json | 1 - src/commands/accounts/logIn.ts | 5 +- src/commands/accounts/signInToTenant.ts | 65 +++++++++++ src/commands/registerCommands.ts | 6 +- src/exportAuthRecord.ts | 18 ++- .../VSCodeAzureSubscriptionProvider.ts | 40 ++----- src/utils/azureTenantSetting.ts | 30 ----- test/azureTenantSetting.test.ts | 104 ----------------- test/signInToTenant.test.ts | 107 ++++++++++++++++++ 10 files changed, 201 insertions(+), 181 deletions(-) create mode 100644 src/commands/accounts/signInToTenant.ts delete mode 100644 src/utils/azureTenantSetting.ts delete mode 100644 test/azureTenantSetting.test.ts create mode 100644 test/signInToTenant.test.ts diff --git a/package.json b/package.json index a16cdc967..6deb59832 100644 --- a/package.json +++ b/package.json @@ -723,12 +723,6 @@ { "title": "Azure", "properties": { - "azure.tenant": { - "type": "string", - "description": "%azure.tenant%", - "default": "", - "scope": "machine" - }, "azureResourceGroups.enableOutputTimestamps": { "type": "boolean", "description": "%azureResourceGroups.enableOutputTimestamps%", diff --git a/package.nls.json b/package.nls.json index 91e0604e7..9d67654dd 100644 --- a/package.nls.json +++ b/package.nls.json @@ -8,7 +8,6 @@ "azureResourceGroups.selectSubscriptions": "Select Subscriptions...", "azureResourceGroups.createResourceGroup": "Create Resource Group...", "azureResourceGroups.createResourceGroupDetail": "The logical grouping for your resources.", - "azure.tenant": "The Microsoft Entra tenant ID or domain to use when signing in to Azure.", "azureResourceGroups.deleteResourceGroup": "Delete Resource Group...", "azureResourceGroups.editTags": "Edit Tags...", "azureResourceGroups.description": "An extension for viewing and managing Azure resources.", diff --git a/src/commands/accounts/logIn.ts b/src/commands/accounts/logIn.ts index 3dc8c7fec..fbe68a413 100644 --- a/src/commands/accounts/logIn.ts +++ b/src/commands/accounts/logIn.ts @@ -6,7 +6,6 @@ import { IActionContext } from '@microsoft/vscode-azext-utils'; import type { SignInOptions } from '@microsoft/vscode-azext-azureauth'; import { ext } from '../../extensionVariables'; -import { getConfiguredAzureTenant } from '../../utils/azureTenantSetting'; // eslint-disable-next-line @typescript-eslint/naming-convention let _isLoggingIn: boolean = false; @@ -17,9 +16,7 @@ export async function logIn(_context: IActionContext, options?: SignInOptions): _isLoggingIn = true; ext.actions.refreshAzureTree(); // Refresh to cause the "logging in" spinner to show ext.actions.refreshTenantTree(); // Refresh to cause the "logging in" spinner to show - const tenantId = getConfiguredAzureTenant(); - const tenant = tenantId ? { tenantId } as Parameters[0] : undefined; - await provider.signIn(tenant, options); + await provider.signIn(undefined, options); } finally { _isLoggingIn = false; // Clear cache to ensure fresh data is fetched after sign-in diff --git a/src/commands/accounts/signInToTenant.ts b/src/commands/accounts/signInToTenant.ts new file mode 100644 index 000000000..97bf2ed75 --- /dev/null +++ b/src/commands/accounts/signInToTenant.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.md in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { AzureAccount, AzureSubscriptionProvider, AzureTenant, isNotSignedInError, TenantIdAndAccount } from '@microsoft/vscode-azext-azureauth'; +import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import { localize } from '../../utils/localize'; + +export async function signInToTenant(context: IActionContext, subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise { + const tenant = await pickTenant(context, subscriptionProvider, account); + if (tenant) { + await subscriptionProvider.signIn(tenant); + } +} + +async function pickTenant(context: IActionContext, subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise { + try { + const picks = await getPicks(subscriptionProvider, account); + if (picks.length) { + return (await context.ui.showQuickPick(picks, { + placeHolder: localize('selectTenantPlaceholder', 'Select a Tenant (Directory) to Sign In To'), + matchOnDescription: true, + })).data; + } + } catch (err) { + if (!isNotSignedInError(err)) { + throw err; + } + } + + return promptForTenantId(context); +} + +async function getPicks(subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise[]> { + const unauthenticatedTenants: AzureTenant[] = []; + const accounts = account ? [account] : await subscriptionProvider.getAccounts(); + for (const account of accounts) { + unauthenticatedTenants.push(...await subscriptionProvider.getUnauthenticatedTenantsForAccount(account)); + } + + const duplicateTenants = new Set(unauthenticatedTenants + .filter((tenant, index, self) => index !== self.findIndex(t => t.tenantId === tenant.tenantId)) + .map(tenant => tenant.tenantId)); + const isDuplicate = (tenantId: string) => duplicateTenants.has(tenantId); + + return unauthenticatedTenants + .sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? '')) + .map(tenant => ({ + label: tenant.displayName ?? '', + description: `${tenant.tenantId}${isDuplicate(tenant.tenantId) ? ` (${tenant.account.label})` : ''}`, + detail: tenant.defaultDomain, + data: tenant, + })); +} + +async function promptForTenantId(context: IActionContext): Promise { + const tenantId = await context.ui.showInputBox({ + placeHolder: localize('enterTenantIdPlaceholder', 'Tenant ID or domain'), + prompt: localize('enterTenantIdPrompt', 'Enter the tenant ID or domain to sign in to.'), + validateInput: value => value?.trim() ? undefined : localize('enterTenantIdValidation', 'Enter a tenant ID or domain.'), + }); + + return tenantId ? { tenantId: tenantId.trim() } as TenantIdAndAccount : undefined; +} diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 08e9aec77..6c645cb78 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { signInToTenant } from '@microsoft/vscode-azext-azureauth'; import { AzExtTreeItem, IActionContext, isAzExtTreeItem, nonNullValue, openUrl, registerCommand, registerCommandWithTreeNodeUnwrapping, registerErrorHandler, registerReportIssueCommand } from '@microsoft/vscode-azext-utils'; import { commands } from 'vscode'; import { askAgentAboutActivityLog } from '../chat/askAgentAboutActivityLog/askAgentAboutActivityLog'; @@ -19,6 +18,7 @@ import { GroupingItem } from '../tree/azure/grouping/GroupingItem'; import { TenantTreeItem } from '../tree/tenants/TenantTreeItem'; import { logIn } from './accounts/logIn'; import { SelectSubscriptionOptions, selectSubscriptions } from './accounts/selectSubscriptions'; +import { signInToTenant } from './accounts/signInToTenant'; import { clearActivities } from './activities/clearActivities'; import { createResource } from './createResource'; import { createResourceGroup } from './createResourceGroup'; @@ -102,8 +102,8 @@ export function registerCommands(): void { registerCommand('azureResourceGroups.logIn', (context: IActionContext) => logIn(context, { clearSessionPreference: true })); registerCommand('azureTenantsView.addAccount', (context: IActionContext) => logIn(context, { clearSessionPreference: true })); registerCommand('azureResourceGroups.selectSubscriptions', (context: IActionContext, options: SelectSubscriptionOptions) => selectSubscriptions(context, options)); - registerCommand('azureResourceGroups.signInToTenant', async () => { - await signInToTenant(await ext.subscriptionProviderFactory()); + registerCommand('azureResourceGroups.signInToTenant', async (context: IActionContext) => { + await signInToTenant(context, await ext.subscriptionProviderFactory()); ext.setClearCacheOnNextLoad('tenant'); ext.actions.refreshTenantTree(); ext.setClearCacheOnNextLoad('azure'); diff --git a/src/exportAuthRecord.ts b/src/exportAuthRecord.ts index 64a1bc129..9941ba6ec 100644 --- a/src/exportAuthRecord.ts +++ b/src/exportAuthRecord.ts @@ -10,7 +10,6 @@ import * as path from 'path'; import type { ExtensionContext } from 'vscode'; import * as vscode from 'vscode'; import { ext } from './extensionVariables'; -import { addConfiguredTenantScope, getConfiguredAzureTenant } from './utils/azureTenantSetting'; import { inCloudShell } from './utils/inCloudShell'; /** @@ -175,7 +174,7 @@ export function getAuthAccountStateManager(): AuthAccountStateManager { */ export async function exportAuthRecord(context: IActionContext, evt?: vscode.AuthenticationSessionsChangeEvent): Promise { const AUTH_PROVIDER_ID = 'microsoft'; // VS Code Azure auth provider - const SCOPES = addConfiguredTenantScope(['https://management.azure.com/.default']); // Default ARM scope, optionally tenant-scoped + const SCOPES = ['https://management.azure.com/.default']; // Default ARM scope context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; @@ -264,8 +263,19 @@ export async function exportAuthRecord(context: IActionContext, evt?: vscode.Aut } // Helper to get tenantId from session or config override -export function getTenantId(session: unknown, context?: IActionContext): string | undefined { - return extractTenantIdFromIdToken(session, context) ?? getConfiguredAzureTenant(); +function getTenantId(session: unknown, context?: IActionContext): string | undefined { + let tenantFromArg: string | undefined = undefined; + try { + // This handles the case if an error is thrown, if the configuration is not registered by any extension + tenantFromArg = vscode.workspace.getConfiguration().get('@azure.argTenant'); + } catch { + // If the configuration is not found, ignore and proceed + ext.outputChannel.appendLine('No @azure.argTenant configuration found. Proceeding without tenant override.'); + } + if (tenantFromArg) { + return tenantFromArg; + } + return extractTenantIdFromIdToken(session, context); } // Helper to extract tenantId (tid) from the idToken of a VS Code authentication session (For MS Auth, this will be present). diff --git a/src/services/VSCodeAzureSubscriptionProvider.ts b/src/services/VSCodeAzureSubscriptionProvider.ts index 9a2a979bc..71f09e15f 100644 --- a/src/services/VSCodeAzureSubscriptionProvider.ts +++ b/src/services/VSCodeAzureSubscriptionProvider.ts @@ -3,30 +3,19 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, SignInOptions, TenantIdAndAccount, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; -import { ext } from '../extensionVariables'; -import { getTenantIdForAuthentication } from '../utils/azureTenantSetting'; -import { isTenantFilteredOut } from '../utils/tenantSelection'; +import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; +import { ext } from '../extensionVariables'; +import { isTenantFilteredOut } from '../utils/tenantSelection'; /** * Extends {@link VSCodeAzureSubscriptionProvider} to additionally filter tenants based on the * Accounts & Tenants view checkbox state. Without this, unselected tenants still count toward * the maximum tenants limit, preventing selected tenants from being processed. */ -class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider { - public override async signIn(tenant?: TenantIdAndAccount, options?: SignInOptions): Promise { - if (tenant?.tenantId) { - return super.signIn(tenant, options); - } - - const tenantId = getTenantIdForAuthentication(undefined); - const tenantForSignIn = tenantId ? { ...tenant, tenantId } as TenantIdAndAccount : tenant; - return super.signIn(tenantForSignIn, options); - } - - public override async getTenantsForAccount(account: AzureAccount, options?: GetTenantsForAccountOptions): Promise { - const tenants = await super.getTenantsForAccount(account, options); +class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider { + public override async getTenantsForAccount(account: AzureAccount, options?: GetTenantsForAccountOptions): Promise { + const tenants = await super.getTenantsForAccount(account, options); // When filtering is enabled, also exclude tenants unchecked in the Accounts & Tenants view. // The base class only filters by selectedSubscriptions config; the Tenants view stores its @@ -39,17 +28,10 @@ class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider } return filtered; } - - return tenants; - } - - protected override getSubscriptionContext(tenant: Partial) { - return super.getSubscriptionContext({ - ...tenant, - tenantId: getTenantIdForAuthentication(tenant.tenantId), - }); - } -} + + return tenants; + } +} let vscodeAzureSubscriptionProvider: VSCodeAzureSubscriptionProvider | undefined; diff --git a/src/utils/azureTenantSetting.ts b/src/utils/azureTenantSetting.ts deleted file mode 100644 index 2bae2994e..000000000 --- a/src/utils/azureTenantSetting.ts +++ /dev/null @@ -1,30 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; - -export const azureTenantSettingKey = 'azure.tenant'; - -export function normalizeConfiguredTenant(tenant: string | undefined): string | undefined { - const trimmedTenant = tenant?.trim(); - return trimmedTenant ? trimmedTenant : undefined; -} - -export function getConfiguredAzureTenant(): string | undefined { - return normalizeConfiguredTenant(vscode.workspace.getConfiguration().get(azureTenantSettingKey)); -} - -export function addConfiguredTenantScope(scopes: readonly string[], tenant: string | undefined = getConfiguredAzureTenant()): string[] { - const normalizedTenant = normalizeConfiguredTenant(tenant); - if (!normalizedTenant) { - return [...scopes]; - } - - return Array.from(new Set([...scopes, `VSCODE_TENANT:${normalizedTenant}`])); -} - -export function getTenantIdForAuthentication(tenantId: string | undefined, configuredTenant: string | undefined = getConfiguredAzureTenant()): string | undefined { - return normalizeConfiguredTenant(tenantId) ?? normalizeConfiguredTenant(configuredTenant); -} diff --git a/test/azureTenantSetting.test.ts b/test/azureTenantSetting.test.ts deleted file mode 100644 index 4a5756997..000000000 --- a/test/azureTenantSetting.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import type { AzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import * as vscode from 'vscode'; -import { logIn } from '../src/commands/accounts/logIn'; -import { ext } from '../src/extensionVariables'; -import { getTenantId } from '../src/exportAuthRecord'; -import { addConfiguredTenantScope, getTenantIdForAuthentication, normalizeConfiguredTenant } from '../src/utils/azureTenantSetting'; - -suite('azureTenantSetting', () => { - test('normalizeConfiguredTenant returns undefined for blank tenants', () => { - assert.strictEqual(normalizeConfiguredTenant(undefined), undefined); - assert.strictEqual(normalizeConfiguredTenant(''), undefined); - assert.strictEqual(normalizeConfiguredTenant(' '), undefined); - }); - - test('normalizeConfiguredTenant trims configured tenants', () => { - assert.strictEqual(normalizeConfiguredTenant(' contoso.onmicrosoft.com '), 'contoso.onmicrosoft.com'); - }); - - test('addConfiguredTenantScope appends VSCODE_TENANT scope when tenant is configured', () => { - assert.deepStrictEqual( - addConfiguredTenantScope(['https://management.azure.com/.default'], 'contoso.onmicrosoft.com'), - ['https://management.azure.com/.default', 'VSCODE_TENANT:contoso.onmicrosoft.com'] - ); - }); - - test('addConfiguredTenantScope leaves scopes unchanged when tenant is not configured', () => { - assert.deepStrictEqual( - addConfiguredTenantScope(['https://management.azure.com/.default'], ' '), - ['https://management.azure.com/.default'] - ); - }); - - test('addConfiguredTenantScope does not duplicate tenant scope', () => { - assert.deepStrictEqual( - addConfiguredTenantScope( - ['https://management.azure.com/.default', 'VSCODE_TENANT:contoso.onmicrosoft.com'], - 'contoso.onmicrosoft.com' - ), - ['https://management.azure.com/.default', 'VSCODE_TENANT:contoso.onmicrosoft.com'] - ); - }); - - test('getTenantIdForAuthentication prefers explicit tenant over configured tenant', () => { - assert.strictEqual(getTenantIdForAuthentication('subscription-tenant', 'configured-tenant'), 'subscription-tenant'); - }); - - test('getTenantIdForAuthentication falls back to configured tenant', () => { - assert.strictEqual(getTenantIdForAuthentication(undefined, 'configured-tenant'), 'configured-tenant'); - }); - - test('getTenantId prefers token tid over configured tenant domain', async () => { - const tenantId = '11111111-2222-3333-4444-555555555555'; - const tokenPayload = Buffer.from(JSON.stringify({ tid: tenantId })).toString('base64'); - const session = { idToken: `header.${tokenPayload}.signature` }; - - try { - await vscode.workspace.getConfiguration().update('azure.tenant', 'contoso.onmicrosoft.com', vscode.ConfigurationTarget.Global); - - assert.strictEqual(getTenantId(session), tenantId); - } finally { - await vscode.workspace.getConfiguration().update('azure.tenant', undefined, vscode.ConfigurationTarget.Global); - } - }); - - test('logIn passes configured azure.tenant to subscription provider signIn', async () => { - const originalSubscriptionProviderFactory = ext.subscriptionProviderFactory; - const originalRefreshAzureTree = ext.actions.refreshAzureTree; - const originalRefreshTenantTree = ext.actions.refreshTenantTree; - const originalRefreshFocusTree = ext.actions.refreshFocusTree; - - let signedInTenantId: string | undefined; - const provider = { - signIn: async (tenant?: { tenantId: string }): Promise => { - signedInTenantId = tenant?.tenantId; - return true; - }, - } as AzureSubscriptionProvider; - - try { - ext.subscriptionProviderFactory = async () => provider; - ext.actions.refreshAzureTree = () => { /* no-op */ }; - ext.actions.refreshTenantTree = () => { /* no-op */ }; - ext.actions.refreshFocusTree = () => { /* no-op */ }; - - await vscode.workspace.getConfiguration().update('azure.tenant', 'contoso.onmicrosoft.com', vscode.ConfigurationTarget.Global); - - await logIn({} as never); - - assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); - } finally { - await vscode.workspace.getConfiguration().update('azure.tenant', undefined, vscode.ConfigurationTarget.Global); - ext.subscriptionProviderFactory = originalSubscriptionProviderFactory; - ext.actions.refreshAzureTree = originalRefreshAzureTree; - ext.actions.refreshTenantTree = originalRefreshTenantTree; - ext.actions.refreshFocusTree = originalRefreshFocusTree; - } - }); -}); diff --git a/test/signInToTenant.test.ts b/test/signInToTenant.test.ts new file mode 100644 index 000000000..d0ce5db24 --- /dev/null +++ b/test/signInToTenant.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { AzureAccount, AzureSubscriptionProvider, AzureTenant, NotSignedInError, RefreshSuggestedEvent, TenantIdAndAccount, getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth'; +import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import type * as vscode from 'vscode'; +import { signInToTenant } from '../src/commands/accounts/signInToTenant'; + +suite('signInToTenant', () => { + test('signs in to selected discovered tenant', async () => { + const account = createAccount(); + const discoveredTenant: AzureTenant = { + account, + tenantId: 'tenantId', + displayName: 'Tenant', + }; + let signedInTenant: TenantIdAndAccount | undefined; + let inputBoxShown = false; + + const context = createTestActionContext({ + showQuickPick: async picks => picks[0], + showInputBox: async () => { + inputBoxShown = true; + return undefined; + }, + }); + const provider = createTestSubscriptionProvider({ + getAccounts: async () => [account], + getUnauthenticatedTenantsForAccount: async () => [discoveredTenant], + signIn: async tenant => { + signedInTenant = tenant; + return true; + }, + }); + + await signInToTenant(context, provider); + + assert.strictEqual(signedInTenant, discoveredTenant); + assert.strictEqual(inputBoxShown, false); + }); + + test('prompts for tenant when no account exists for tenant discovery', async () => { + let signedInTenantId: string | undefined; + let quickPickShown = false; + + const context = createTestActionContext({ + showQuickPick: async picks => { + quickPickShown = true; + return picks[0]; + }, + showInputBox: async () => 'contoso.onmicrosoft.com', + }); + const provider = createTestSubscriptionProvider({ + getAccounts: async () => { + throw new NotSignedInError(); + }, + signIn: async tenant => { + signedInTenantId = tenant?.tenantId; + return true; + }, + }); + + await signInToTenant(context, provider); + + assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); + assert.strictEqual(quickPickShown, false); + }); +}); + +function createAccount(): AzureAccount { + return { + id: 'accountId', + label: 'Account', + environment: getConfiguredAzureEnv(), + }; +} + +function createTestActionContext(options: { + showQuickPick: (picks: IAzureQuickPickItem[]) => Promise>; + showInputBox: () => Promise; +}): IActionContext { + return { + ui: { + showQuickPick: options.showQuickPick, + showInputBox: options.showInputBox, + }, + } as unknown as IActionContext; +} + +function createTestSubscriptionProvider(options: { + getAccounts?: () => Promise; + getUnauthenticatedTenantsForAccount?: (account: AzureAccount) => Promise; + signIn: (tenant?: TenantIdAndAccount) => Promise; +}): AzureSubscriptionProvider { + return { + onRefreshSuggested: (() => ({ dispose: () => { /* no-op */ } })) as unknown as vscode.Event, + getAccounts: options.getAccounts ?? (async () => []), + getUnauthenticatedTenantsForAccount: options.getUnauthenticatedTenantsForAccount ?? (async () => []), + signIn: options.signIn, + getAvailableSubscriptions: async () => [], + getTenantsForAccount: async () => [], + getSubscriptionsForTenant: async () => [], + }; +} From 19bcdf8f91a40121b4fd4510bcc11e5a0ba4c552 Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 16:34:43 +0200 Subject: [PATCH 06/10] Persist manual tenant for subscription discovery --- src/commands/accounts/signInToTenant.ts | 25 ++++++++---- .../VSCodeAzureSubscriptionProvider.ts | 22 +++++++---- src/utils/manualSignInTenant.ts | 39 +++++++++++++++++++ test/signInToTenant.test.ts | 10 +++++ 4 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 src/utils/manualSignInTenant.ts diff --git a/src/commands/accounts/signInToTenant.ts b/src/commands/accounts/signInToTenant.ts index 97bf2ed75..50050f25b 100644 --- a/src/commands/accounts/signInToTenant.ts +++ b/src/commands/accounts/signInToTenant.ts @@ -5,23 +5,33 @@ import { AzureAccount, AzureSubscriptionProvider, AzureTenant, isNotSignedInError, TenantIdAndAccount } from '@microsoft/vscode-azext-azureauth'; import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import { setManualSignInTenant } from '../../utils/manualSignInTenant'; import { localize } from '../../utils/localize'; export async function signInToTenant(context: IActionContext, subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise { - const tenant = await pickTenant(context, subscriptionProvider, account); - if (tenant) { - await subscriptionProvider.signIn(tenant); + const tenantPick = await pickTenant(context, subscriptionProvider, account); + if (tenantPick) { + const signedIn = await subscriptionProvider.signIn(tenantPick.tenant); + if (signedIn && tenantPick.isManual) { + await setManualSignInTenant(tenantPick.tenant.tenantId); + } } } -async function pickTenant(context: IActionContext, subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise { +interface TenantPick { + tenant: TenantIdAndAccount; + isManual: boolean; +} + +async function pickTenant(context: IActionContext, subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise { try { const picks = await getPicks(subscriptionProvider, account); if (picks.length) { - return (await context.ui.showQuickPick(picks, { + const pick = await context.ui.showQuickPick(picks, { placeHolder: localize('selectTenantPlaceholder', 'Select a Tenant (Directory) to Sign In To'), matchOnDescription: true, - })).data; + }); + return { tenant: pick.data, isManual: false }; } } catch (err) { if (!isNotSignedInError(err)) { @@ -29,7 +39,8 @@ async function pickTenant(context: IActionContext, subscriptionProvider: AzureSu } } - return promptForTenantId(context); + const tenant = await promptForTenantId(context); + return tenant ? { tenant, isManual: true } : undefined; } async function getPicks(subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise[]> { diff --git a/src/services/VSCodeAzureSubscriptionProvider.ts b/src/services/VSCodeAzureSubscriptionProvider.ts index 71f09e15f..fc72ed7fd 100644 --- a/src/services/VSCodeAzureSubscriptionProvider.ts +++ b/src/services/VSCodeAzureSubscriptionProvider.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; -import { ext } from '../extensionVariables'; -import { isTenantFilteredOut } from '../utils/tenantSelection'; +import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, TenantIdAndAccount, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; +import { ext } from '../extensionVariables'; +import { getTenantIdForAuthentication } from '../utils/manualSignInTenant'; +import { isTenantFilteredOut } from '../utils/tenantSelection'; /** * Extends {@link VSCodeAzureSubscriptionProvider} to additionally filter tenants based on the @@ -29,9 +30,16 @@ class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider return filtered; } - return tenants; - } -} + return tenants; + } + + protected override getSubscriptionContext(tenant: Partial) { + return super.getSubscriptionContext({ + ...tenant, + tenantId: getTenantIdForAuthentication(tenant.tenantId), + }); + } +} let vscodeAzureSubscriptionProvider: VSCodeAzureSubscriptionProvider | undefined; diff --git a/src/utils/manualSignInTenant.ts b/src/utils/manualSignInTenant.ts new file mode 100644 index 000000000..88c72bad8 --- /dev/null +++ b/src/utils/manualSignInTenant.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ext } from '../extensionVariables'; + +const ManualSignInTenantKey = 'manualSignInTenant'; +let testingManualSignInTenant: string | undefined; + +export function normalizeTenant(tenant: string | undefined): string | undefined { + const trimmedTenant = tenant?.trim(); + return trimmedTenant ? trimmedTenant : undefined; +} + +export function getManualSignInTenant(): string | undefined { + if (testingManualSignInTenant !== undefined) { + return testingManualSignInTenant; + } + + return normalizeTenant(ext.context.globalState.get(ManualSignInTenantKey)); +} + +export async function setManualSignInTenant(tenant: string): Promise { + if (!ext.context) { + testingManualSignInTenant = normalizeTenant(tenant); + return; + } + + await ext.context.globalState.update(ManualSignInTenantKey, normalizeTenant(tenant)); +} + +export function getTenantIdForAuthentication(tenantId: string | undefined): string | undefined { + return normalizeTenant(tenantId) ?? getManualSignInTenant(); +} + +export function resetManualSignInTenantForTests(): void { + testingManualSignInTenant = undefined; +} diff --git a/test/signInToTenant.test.ts b/test/signInToTenant.test.ts index d0ce5db24..c51b15828 100644 --- a/test/signInToTenant.test.ts +++ b/test/signInToTenant.test.ts @@ -8,8 +8,17 @@ import { AzureAccount, AzureSubscriptionProvider, AzureTenant, NotSignedInError, import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; import type * as vscode from 'vscode'; import { signInToTenant } from '../src/commands/accounts/signInToTenant'; +import { getManualSignInTenant, resetManualSignInTenantForTests } from '../src/utils/manualSignInTenant'; suite('signInToTenant', () => { + setup(() => { + resetManualSignInTenantForTests(); + }); + + teardown(() => { + resetManualSignInTenantForTests(); + }); + test('signs in to selected discovered tenant', async () => { const account = createAccount(); const discoveredTenant: AzureTenant = { @@ -67,6 +76,7 @@ suite('signInToTenant', () => { assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); assert.strictEqual(quickPickShown, false); + assert.strictEqual(getManualSignInTenant(), 'contoso.onmicrosoft.com'); }); }); From 793aa9971523df11f8f21713bed879f644cf635a Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 19:05:56 +0200 Subject: [PATCH 07/10] Address manual tenant sign-in review feedback --- src/commands/accounts/signInToTenant.ts | 20 +++--- src/utils/manualSignInTenant.ts | 9 ++- test/signInToTenant.test.ts | 82 ++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/src/commands/accounts/signInToTenant.ts b/src/commands/accounts/signInToTenant.ts index 50050f25b..c2584d340 100644 --- a/src/commands/accounts/signInToTenant.ts +++ b/src/commands/accounts/signInToTenant.ts @@ -3,12 +3,18 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureAccount, AzureSubscriptionProvider, AzureTenant, isNotSignedInError, TenantIdAndAccount } from '@microsoft/vscode-azext-azureauth'; +import { AzureAccount, AzureSubscriptionProvider, AzureTenant, isNotSignedInError, SignInOptions, TenantIdAndAccount } from '@microsoft/vscode-azext-azureauth'; import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; import { setManualSignInTenant } from '../../utils/manualSignInTenant'; import { localize } from '../../utils/localize'; -export async function signInToTenant(context: IActionContext, subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise { +type ManualTenantSignIn = Partial & Pick; +type TenantSignIn = TenantIdAndAccount | ManualTenantSignIn; +type ManualTenantSubscriptionProvider = Omit & { + signIn(tenant?: Partial, options?: SignInOptions): Promise; +}; + +export async function signInToTenant(context: IActionContext, subscriptionProvider: ManualTenantSubscriptionProvider, account?: AzureAccount): Promise { const tenantPick = await pickTenant(context, subscriptionProvider, account); if (tenantPick) { const signedIn = await subscriptionProvider.signIn(tenantPick.tenant); @@ -19,11 +25,11 @@ export async function signInToTenant(context: IActionContext, subscriptionProvid } interface TenantPick { - tenant: TenantIdAndAccount; + tenant: TenantSignIn; isManual: boolean; } -async function pickTenant(context: IActionContext, subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise { +async function pickTenant(context: IActionContext, subscriptionProvider: ManualTenantSubscriptionProvider, account?: AzureAccount): Promise { try { const picks = await getPicks(subscriptionProvider, account); if (picks.length) { @@ -58,19 +64,19 @@ async function getPicks(subscriptionProvider: AzureSubscriptionProvider, account return unauthenticatedTenants .sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? '')) .map(tenant => ({ - label: tenant.displayName ?? '', + label: tenant.displayName ?? tenant.defaultDomain ?? tenant.tenantId, description: `${tenant.tenantId}${isDuplicate(tenant.tenantId) ? ` (${tenant.account.label})` : ''}`, detail: tenant.defaultDomain, data: tenant, })); } -async function promptForTenantId(context: IActionContext): Promise { +async function promptForTenantId(context: IActionContext): Promise { const tenantId = await context.ui.showInputBox({ placeHolder: localize('enterTenantIdPlaceholder', 'Tenant ID or domain'), prompt: localize('enterTenantIdPrompt', 'Enter the tenant ID or domain to sign in to.'), validateInput: value => value?.trim() ? undefined : localize('enterTenantIdValidation', 'Enter a tenant ID or domain.'), }); - return tenantId ? { tenantId: tenantId.trim() } as TenantIdAndAccount : undefined; + return tenantId ? { tenantId: tenantId.trim() } : undefined; } diff --git a/src/utils/manualSignInTenant.ts b/src/utils/manualSignInTenant.ts index 88c72bad8..acf3381e7 100644 --- a/src/utils/manualSignInTenant.ts +++ b/src/utils/manualSignInTenant.ts @@ -18,6 +18,10 @@ export function getManualSignInTenant(): string | undefined { return testingManualSignInTenant; } + if (!ext.context) { + return undefined; + } + return normalizeTenant(ext.context.globalState.get(ManualSignInTenantKey)); } @@ -34,6 +38,9 @@ export function getTenantIdForAuthentication(tenantId: string | undefined): stri return normalizeTenant(tenantId) ?? getManualSignInTenant(); } -export function resetManualSignInTenantForTests(): void { +export async function resetManualSignInTenantForTests(): Promise { testingManualSignInTenant = undefined; + if (ext.context) { + await ext.context.globalState.update(ManualSignInTenantKey, undefined); + } } diff --git a/test/signInToTenant.test.ts b/test/signInToTenant.test.ts index c51b15828..1e8f9dd1a 100644 --- a/test/signInToTenant.test.ts +++ b/test/signInToTenant.test.ts @@ -8,15 +8,15 @@ import { AzureAccount, AzureSubscriptionProvider, AzureTenant, NotSignedInError, import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; import type * as vscode from 'vscode'; import { signInToTenant } from '../src/commands/accounts/signInToTenant'; -import { getManualSignInTenant, resetManualSignInTenantForTests } from '../src/utils/manualSignInTenant'; +import { getManualSignInTenant, resetManualSignInTenantForTests, setManualSignInTenant } from '../src/utils/manualSignInTenant'; suite('signInToTenant', () => { - setup(() => { - resetManualSignInTenantForTests(); + setup(async () => { + await resetManualSignInTenantForTests(); }); - teardown(() => { - resetManualSignInTenantForTests(); + teardown(async () => { + await resetManualSignInTenantForTests(); }); test('signs in to selected discovered tenant', async () => { @@ -26,7 +26,7 @@ suite('signInToTenant', () => { tenantId: 'tenantId', displayName: 'Tenant', }; - let signedInTenant: TenantIdAndAccount | undefined; + let signedInTenant: Partial | undefined; let inputBoxShown = false; const context = createTestActionContext({ @@ -53,6 +53,7 @@ suite('signInToTenant', () => { test('prompts for tenant when no account exists for tenant discovery', async () => { let signedInTenantId: string | undefined; + let signedInTenantAccount: AzureAccount | undefined; let quickPickShown = false; const context = createTestActionContext({ @@ -68,6 +69,7 @@ suite('signInToTenant', () => { }, signIn: async tenant => { signedInTenantId = tenant?.tenantId; + signedInTenantAccount = tenant?.account; return true; }, }); @@ -75,9 +77,71 @@ suite('signInToTenant', () => { await signInToTenant(context, provider); assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); + assert.strictEqual(signedInTenantAccount, undefined); assert.strictEqual(quickPickShown, false); assert.strictEqual(getManualSignInTenant(), 'contoso.onmicrosoft.com'); }); + + test('uses tenant domain as quick pick label when display name is missing', async () => { + const account = createAccount(); + const discoveredTenant: AzureTenant = { + account, + tenantId: 'tenantId', + defaultDomain: 'contoso.onmicrosoft.com', + }; + let shownLabel: string | undefined; + + const context = createTestActionContext({ + showQuickPick: async picks => { + shownLabel = picks[0].label; + return picks[0]; + }, + showInputBox: async () => undefined, + }); + const provider = createTestSubscriptionProvider({ + getAccounts: async () => [account], + getUnauthenticatedTenantsForAccount: async () => [discoveredTenant], + signIn: async () => true, + }); + + await signInToTenant(context, provider); + + assert.strictEqual(shownLabel, 'contoso.onmicrosoft.com'); + }); + + test('uses tenant id as quick pick label when display name and domain are missing', async () => { + const account = createAccount(); + const discoveredTenant: AzureTenant = { + account, + tenantId: 'tenantId', + }; + let shownLabel: string | undefined; + + const context = createTestActionContext({ + showQuickPick: async picks => { + shownLabel = picks[0].label; + return picks[0]; + }, + showInputBox: async () => undefined, + }); + const provider = createTestSubscriptionProvider({ + getAccounts: async () => [account], + getUnauthenticatedTenantsForAccount: async () => [discoveredTenant], + signIn: async () => true, + }); + + await signInToTenant(context, provider); + + assert.strictEqual(shownLabel, 'tenantId'); + }); + + test('resetManualSignInTenantForTests clears persisted tenant', async () => { + await setManualSignInTenant('contoso.onmicrosoft.com'); + + await resetManualSignInTenantForTests(); + + assert.strictEqual(getManualSignInTenant(), undefined); + }); }); function createAccount(): AzureAccount { @@ -103,10 +167,12 @@ function createTestActionContext(options: { function createTestSubscriptionProvider(options: { getAccounts?: () => Promise; getUnauthenticatedTenantsForAccount?: (account: AzureAccount) => Promise; - signIn: (tenant?: TenantIdAndAccount) => Promise; + signIn: (tenant?: Partial) => Promise; }): AzureSubscriptionProvider { + const onRefreshSuggested: vscode.Event = (_listener, _thisArgs, _disposables) => ({ dispose: () => { /* no-op */ } }); + return { - onRefreshSuggested: (() => ({ dispose: () => { /* no-op */ } })) as unknown as vscode.Event, + onRefreshSuggested, getAccounts: options.getAccounts ?? (async () => []), getUnauthenticatedTenantsForAccount: options.getUnauthenticatedTenantsForAccount ?? (async () => []), signIn: options.signIn, From 1cabd47a012d7e70ce75d30825c6a161c006be65 Mon Sep 17 00:00:00 2001 From: CoffeeKanzler <82139476+CoffeeKanzler@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:21:17 +0200 Subject: [PATCH 08/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/utils/manualSignInTenant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/manualSignInTenant.ts b/src/utils/manualSignInTenant.ts index acf3381e7..c22af77a5 100644 --- a/src/utils/manualSignInTenant.ts +++ b/src/utils/manualSignInTenant.ts @@ -1,6 +1,6 @@ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. + * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { ext } from '../extensionVariables'; From 2cb82475cc082c21acb644805217d980e37dcf10 Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Tue, 23 Jun 2026 19:36:20 +0200 Subject: [PATCH 09/10] Sort tenant picks by label and test empty-discovery fallback - Sort the tenant quick pick by the same display/domain/id fallback used for the label, so tenants without a display name no longer clump at the top. - Add a test for the manual-entry fallback when discovery returns no tenants (account exists but has no unauthenticated tenants). --- src/commands/accounts/signInToTenant.ts | 5 +++-- test/signInToTenant.test.ts | 28 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/commands/accounts/signInToTenant.ts b/src/commands/accounts/signInToTenant.ts index c2584d340..569c3144d 100644 --- a/src/commands/accounts/signInToTenant.ts +++ b/src/commands/accounts/signInToTenant.ts @@ -60,11 +60,12 @@ async function getPicks(subscriptionProvider: AzureSubscriptionProvider, account .filter((tenant, index, self) => index !== self.findIndex(t => t.tenantId === tenant.tenantId)) .map(tenant => tenant.tenantId)); const isDuplicate = (tenantId: string) => duplicateTenants.has(tenantId); + const tenantLabel = (tenant: AzureTenant) => tenant.displayName ?? tenant.defaultDomain ?? tenant.tenantId; return unauthenticatedTenants - .sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? '')) + .sort((a, b) => tenantLabel(a).localeCompare(tenantLabel(b))) .map(tenant => ({ - label: tenant.displayName ?? tenant.defaultDomain ?? tenant.tenantId, + label: tenantLabel(tenant), description: `${tenant.tenantId}${isDuplicate(tenant.tenantId) ? ` (${tenant.account.label})` : ''}`, detail: tenant.defaultDomain, data: tenant, diff --git a/test/signInToTenant.test.ts b/test/signInToTenant.test.ts index 1e8f9dd1a..986e780bb 100644 --- a/test/signInToTenant.test.ts +++ b/test/signInToTenant.test.ts @@ -82,6 +82,34 @@ suite('signInToTenant', () => { assert.strictEqual(getManualSignInTenant(), 'contoso.onmicrosoft.com'); }); + test('prompts for tenant when discovery returns no tenants', async () => { + const account = createAccount(); + let signedInTenantId: string | undefined; + let quickPickShown = false; + + const context = createTestActionContext({ + showQuickPick: async picks => { + quickPickShown = true; + return picks[0]; + }, + showInputBox: async () => 'contoso.onmicrosoft.com', + }); + const provider = createTestSubscriptionProvider({ + getAccounts: async () => [account], + getUnauthenticatedTenantsForAccount: async () => [], + signIn: async tenant => { + signedInTenantId = tenant?.tenantId; + return true; + }, + }); + + await signInToTenant(context, provider); + + assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); + assert.strictEqual(quickPickShown, false); + assert.strictEqual(getManualSignInTenant(), 'contoso.onmicrosoft.com'); + }); + test('uses tenant domain as quick pick label when display name is missing', async () => { const account = createAccount(); const discoveredTenant: AzureTenant = { From 9cff715cd5663f97bdddc288b5da2777a595fd61 Mon Sep 17 00:00:00 2001 From: coffeekanzler Date: Mon, 6 Jul 2026 11:47:58 +0200 Subject: [PATCH 10/10] Replace globalState persistence with azure.tenant setting Address review feedback from #1511: - Delegate the tenant picker to the auth package's signInToTenant and fall back to a tenant ID/domain input box only on NotSignedInError. - Drop the manualSignInTenant globalState; persist the manually entered tenant in a new azure.tenant setting (machine scope) instead. - Move the conditional-access discovery fix into getTenantsForAccount: when tenant discovery throws or returns no tenants, inject a synthetic AzureTenant from the setting so the normal per-tenant subscription flow runs against the tenant-scoped session. This removes the getSubscriptionContext override entirely. Claude-Session: https://claude.ai/code/session_01Cqc27MN7o36f4avwMAyj3M --- package.json | 6 + package.nls.json | 1 + src/commands/accounts/signInToTenant.ts | 113 ++++------ .../VSCodeAzureSubscriptionProvider.ts | 49 +++-- src/utils/azureTenantSetting.ts | 41 ++++ src/utils/manualSignInTenant.ts | 46 ---- test/signInToTenant.test.ts | 200 ++++++++---------- 7 files changed, 206 insertions(+), 250 deletions(-) create mode 100644 src/utils/azureTenantSetting.ts delete mode 100644 src/utils/manualSignInTenant.ts diff --git a/package.json b/package.json index 6deb59832..a16cdc967 100644 --- a/package.json +++ b/package.json @@ -723,6 +723,12 @@ { "title": "Azure", "properties": { + "azure.tenant": { + "type": "string", + "description": "%azure.tenant%", + "default": "", + "scope": "machine" + }, "azureResourceGroups.enableOutputTimestamps": { "type": "boolean", "description": "%azureResourceGroups.enableOutputTimestamps%", diff --git a/package.nls.json b/package.nls.json index 9d67654dd..b437cc6d7 100644 --- a/package.nls.json +++ b/package.nls.json @@ -5,6 +5,7 @@ "azureResourceGroups.logIn": "Sign In", "azureTenantsView.addAccount": "Add account", "azureResourceGroups.signInToTenant": "Sign in to Tenant (Directory)...", + "azure.tenant": "The Microsoft Entra tenant (directory) ID or domain to sign in to when normal tenant discovery is blocked by conditional access. Leave empty unless sign-in fails to load resources.", "azureResourceGroups.selectSubscriptions": "Select Subscriptions...", "azureResourceGroups.createResourceGroup": "Create Resource Group...", "azureResourceGroups.createResourceGroupDetail": "The logical grouping for your resources.", diff --git a/src/commands/accounts/signInToTenant.ts b/src/commands/accounts/signInToTenant.ts index 569c3144d..f2c144dd7 100644 --- a/src/commands/accounts/signInToTenant.ts +++ b/src/commands/accounts/signInToTenant.ts @@ -1,83 +1,54 @@ /*--------------------------------------------------------------------------------------------- -* Copyright (c) Microsoft Corporation. All rights reserved. -* Licensed under the MIT License. See License.md in the project root for license information. -*--------------------------------------------------------------------------------------------*/ + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ -import { AzureAccount, AzureSubscriptionProvider, AzureTenant, isNotSignedInError, SignInOptions, TenantIdAndAccount } from '@microsoft/vscode-azext-azureauth'; -import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import { setManualSignInTenant } from '../../utils/manualSignInTenant'; +import { AzureAccount, AzureSubscriptionProvider, isNotSignedInError, signInToTenant as signInToTenantFromAccounts, TenantIdAndAccount } from '@microsoft/vscode-azext-azureauth'; +import { IActionContext } from '@microsoft/vscode-azext-utils'; +import { setConfiguredAzureTenant } from '../../utils/azureTenantSetting'; import { localize } from '../../utils/localize'; -type ManualTenantSignIn = Partial & Pick; -type TenantSignIn = TenantIdAndAccount | ManualTenantSignIn; -type ManualTenantSubscriptionProvider = Omit & { - signIn(tenant?: Partial, options?: SignInOptions): Promise; -}; - -export async function signInToTenant(context: IActionContext, subscriptionProvider: ManualTenantSubscriptionProvider, account?: AzureAccount): Promise { - const tenantPick = await pickTenant(context, subscriptionProvider, account); - if (tenantPick) { - const signedIn = await subscriptionProvider.signIn(tenantPick.tenant); - if (signedIn && tenantPick.isManual) { - await setManualSignInTenant(tenantPick.tenant.tenantId); - } - } -} - -interface TenantPick { - tenant: TenantSignIn; - isManual: boolean; -} - -async function pickTenant(context: IActionContext, subscriptionProvider: ManualTenantSubscriptionProvider, account?: AzureAccount): Promise { +/** + * Signs in to a specific tenant (directory). + * + * Delegates to the auth package's tenant picker, which lists unauthenticated tenants discovered for + * the signed-in accounts. When the user is not signed in to any account there are no tenants to + * enumerate, so this falls back to prompting for a tenant ID or domain. Manual entry is the only way + * to unblock users whose tenant enforces conditional access that prevents the initial common-endpoint + * sign-in: they have no authenticated account to enumerate tenants from, so they must direct the very + * first sign-in at their tenant (similar to `az login --tenant `). The entered tenant is + * persisted to the `azure.tenant` setting so account-level tenant discovery can fall back to it on + * later reloads (see {@link getConfiguredTenantFallback}); otherwise the sign-in succeeds but no + * resources load. + * + * The `signInToTenantFromAccounts` parameter is injectable for testing; production callers use the + * auth package's implementation. + */ +export async function signInToTenant( + context: IActionContext, + subscriptionProvider: AzureSubscriptionProvider, + signInFromAccounts: (provider: AzureSubscriptionProvider, account?: AzureAccount) => Promise = signInToTenantFromAccounts, +): Promise { try { - const picks = await getPicks(subscriptionProvider, account); - if (picks.length) { - const pick = await context.ui.showQuickPick(picks, { - placeHolder: localize('selectTenantPlaceholder', 'Select a Tenant (Directory) to Sign In To'), - matchOnDescription: true, - }); - return { tenant: pick.data, isManual: false }; - } - } catch (err) { - if (!isNotSignedInError(err)) { - throw err; + await signInFromAccounts(subscriptionProvider); + return; + } catch (error) { + if (!isNotSignedInError(error)) { + throw error; } } - const tenant = await promptForTenantId(context); - return tenant ? { tenant, isManual: true } : undefined; -} - -async function getPicks(subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise[]> { - const unauthenticatedTenants: AzureTenant[] = []; - const accounts = account ? [account] : await subscriptionProvider.getAccounts(); - for (const account of accounts) { - unauthenticatedTenants.push(...await subscriptionProvider.getUnauthenticatedTenantsForAccount(account)); - } - - const duplicateTenants = new Set(unauthenticatedTenants - .filter((tenant, index, self) => index !== self.findIndex(t => t.tenantId === tenant.tenantId)) - .map(tenant => tenant.tenantId)); - const isDuplicate = (tenantId: string) => duplicateTenants.has(tenantId); - const tenantLabel = (tenant: AzureTenant) => tenant.displayName ?? tenant.defaultDomain ?? tenant.tenantId; - - return unauthenticatedTenants - .sort((a, b) => tenantLabel(a).localeCompare(tenantLabel(b))) - .map(tenant => ({ - label: tenantLabel(tenant), - description: `${tenant.tenantId}${isDuplicate(tenant.tenantId) ? ` (${tenant.account.label})` : ''}`, - detail: tenant.defaultDomain, - data: tenant, - })); -} - -async function promptForTenantId(context: IActionContext): Promise { - const tenantId = await context.ui.showInputBox({ + const tenantId = (await context.ui.showInputBox({ placeHolder: localize('enterTenantIdPlaceholder', 'Tenant ID or domain'), prompt: localize('enterTenantIdPrompt', 'Enter the tenant ID or domain to sign in to.'), validateInput: value => value?.trim() ? undefined : localize('enterTenantIdValidation', 'Enter a tenant ID or domain.'), - }); - - return tenantId ? { tenantId: tenantId.trim() } : undefined; + })).trim(); + + // The account is filled in interactively during sign-in, so it is omitted here. + const signedIn = await subscriptionProvider.signIn({ tenantId } as TenantIdAndAccount); + if (signedIn) { + // Persist only after a successful sign-in so a typo'd or cancelled attempt doesn't leave a + // broken tenant in the user's settings that later tenant discovery would keep injecting. + await setConfiguredAzureTenant(tenantId); + } } diff --git a/src/services/VSCodeAzureSubscriptionProvider.ts b/src/services/VSCodeAzureSubscriptionProvider.ts index fc72ed7fd..0b9f6b0b7 100644 --- a/src/services/VSCodeAzureSubscriptionProvider.ts +++ b/src/services/VSCodeAzureSubscriptionProvider.ts @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, TenantIdAndAccount, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; -import { ext } from '../extensionVariables'; -import { getTenantIdForAuthentication } from '../utils/manualSignInTenant'; -import { isTenantFilteredOut } from '../utils/tenantSelection'; +import { AzureAccount, AzureTenant, GetTenantsForAccountOptions, VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { getSelectedTenantAndSubscriptionIds } from '../commands/accounts/selectSubscriptions'; +import { ext } from '../extensionVariables'; +import { getConfiguredTenantFallback } from '../utils/azureTenantSetting'; +import { isTenantFilteredOut } from '../utils/tenantSelection'; /** * Extends {@link VSCodeAzureSubscriptionProvider} to additionally filter tenants based on the @@ -16,7 +16,31 @@ import { isTenantFilteredOut } from '../utils/tenantSelection'; */ class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider { public override async getTenantsForAccount(account: AzureAccount, options?: GetTenantsForAccountOptions): Promise { - const tenants = await super.getTenantsForAccount(account, options); + let tenants: AzureTenant[]; + try { + tenants = await super.getTenantsForAccount(account, options); + } catch (error) { + // Tenant discovery can fail outright when conditional access blocks the silent + // common-endpoint token. Fall back to the configured tenant if there is one; + // otherwise surface the original error. + const fallback = getConfiguredTenantFallback(account); + if (fallback) { + this.logForAccount(account, `Tenant discovery failed (${error instanceof Error ? error.message : String(error)}); using configured tenant ${fallback.tenantId}`); + return [fallback]; + } + throw error; + } + + // Conditional-access bootstrap: when discovery yields no tenants (the account can't + // enumerate tenants because conditional access blocks the silent common-endpoint token), + // inject the configured tenant so the normal per-tenant sign-in/subscription flow can run. + if (tenants.length === 0) { + const fallback = getConfiguredTenantFallback(account); + if (fallback) { + this.logForAccount(account, `Tenant discovery returned no tenants; using configured tenant ${fallback.tenantId}`); + return [fallback]; + } + } // When filtering is enabled, also exclude tenants unchecked in the Accounts & Tenants view. // The base class only filters by selectedSubscriptions config; the Tenants view stores its @@ -30,16 +54,9 @@ class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider return filtered; } - return tenants; - } - - protected override getSubscriptionContext(tenant: Partial) { - return super.getSubscriptionContext({ - ...tenant, - tenantId: getTenantIdForAuthentication(tenant.tenantId), - }); - } -} + return tenants; + } +} let vscodeAzureSubscriptionProvider: VSCodeAzureSubscriptionProvider | undefined; diff --git a/src/utils/azureTenantSetting.ts b/src/utils/azureTenantSetting.ts new file mode 100644 index 000000000..e9f0a80c1 --- /dev/null +++ b/src/utils/azureTenantSetting.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureAccount, AzureTenant } from '@microsoft/vscode-azext-azureauth'; +import * as vscode from 'vscode'; + +export const azureTenantSettingKey = 'azure.tenant'; + +export function normalizeConfiguredTenant(tenant: string | undefined): string | undefined { + const trimmedTenant = tenant?.trim(); + return trimmedTenant ? trimmedTenant : undefined; +} + +export function getConfiguredAzureTenant(): string | undefined { + return normalizeConfiguredTenant(vscode.workspace.getConfiguration().get(azureTenantSettingKey)); +} + +export async function setConfiguredAzureTenant(tenant: string | undefined): Promise { + await vscode.workspace.getConfiguration().update(azureTenantSettingKey, normalizeConfiguredTenant(tenant), vscode.ConfigurationTarget.Global); +} + +/** + * Returns the configured tenant as a synthetic {@link AzureTenant} for the given account, or + * `undefined` when no tenant is configured. + * + * This is the conditional-access bootstrap: when ARM tenant discovery returns nothing for an + * account (the account can't enumerate tenants because conditional access blocks the silent + * common-endpoint token), the configured tenant is injected so the normal per-tenant sign-in and + * subscription flow can run against the tenant's own session. + * + * `displayName` is set to the configured value because the Accounts & Tenants view renders every + * tenant through `TenantTreeItem`, which requires a non-null `displayName`; without one the view + * would throw and render empty. The real directory name isn't known (that's what discovery would + * have told us), so the entered ID/domain is the best label available. + */ +export function getConfiguredTenantFallback(account: AzureAccount, configuredTenant: string | undefined = getConfiguredAzureTenant()): AzureTenant | undefined { + const normalizedTenant = normalizeConfiguredTenant(configuredTenant); + return normalizedTenant ? { tenantId: normalizedTenant, displayName: normalizedTenant, account } : undefined; +} diff --git a/src/utils/manualSignInTenant.ts b/src/utils/manualSignInTenant.ts deleted file mode 100644 index c22af77a5..000000000 --- a/src/utils/manualSignInTenant.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.md in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ext } from '../extensionVariables'; - -const ManualSignInTenantKey = 'manualSignInTenant'; -let testingManualSignInTenant: string | undefined; - -export function normalizeTenant(tenant: string | undefined): string | undefined { - const trimmedTenant = tenant?.trim(); - return trimmedTenant ? trimmedTenant : undefined; -} - -export function getManualSignInTenant(): string | undefined { - if (testingManualSignInTenant !== undefined) { - return testingManualSignInTenant; - } - - if (!ext.context) { - return undefined; - } - - return normalizeTenant(ext.context.globalState.get(ManualSignInTenantKey)); -} - -export async function setManualSignInTenant(tenant: string): Promise { - if (!ext.context) { - testingManualSignInTenant = normalizeTenant(tenant); - return; - } - - await ext.context.globalState.update(ManualSignInTenantKey, normalizeTenant(tenant)); -} - -export function getTenantIdForAuthentication(tenantId: string | undefined): string | undefined { - return normalizeTenant(tenantId) ?? getManualSignInTenant(); -} - -export async function resetManualSignInTenantForTests(): Promise { - testingManualSignInTenant = undefined; - if (ext.context) { - await ext.context.globalState.update(ManualSignInTenantKey, undefined); - } -} diff --git a/test/signInToTenant.test.ts b/test/signInToTenant.test.ts index 986e780bb..bb778d110 100644 --- a/test/signInToTenant.test.ts +++ b/test/signInToTenant.test.ts @@ -1,174 +1,152 @@ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. + * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { AzureAccount, AzureSubscriptionProvider, AzureTenant, NotSignedInError, RefreshSuggestedEvent, TenantIdAndAccount, getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth'; -import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import type * as vscode from 'vscode'; +import { AzureAccount, AzureSubscriptionProvider, NotSignedInError, TenantIdAndAccount, getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth'; +import { IActionContext } from '@microsoft/vscode-azext-utils'; import { signInToTenant } from '../src/commands/accounts/signInToTenant'; -import { getManualSignInTenant, resetManualSignInTenantForTests, setManualSignInTenant } from '../src/utils/manualSignInTenant'; +import { TenantTreeItem } from '../src/tree/tenants/TenantTreeItem'; +import { getConfiguredAzureTenant, getConfiguredTenantFallback, normalizeConfiguredTenant, setConfiguredAzureTenant } from '../src/utils/azureTenantSetting'; suite('signInToTenant', () => { - setup(async () => { - await resetManualSignInTenantForTests(); - }); - teardown(async () => { - await resetManualSignInTenantForTests(); + await setConfiguredAzureTenant(undefined); }); - test('signs in to selected discovered tenant', async () => { - const account = createAccount(); - const discoveredTenant: AzureTenant = { - account, - tenantId: 'tenantId', - displayName: 'Tenant', - }; - let signedInTenant: Partial | undefined; + test('delegates to the auth package picker when an account is signed in', async () => { + let delegated = false; let inputBoxShown = false; + let signInCalled = false; const context = createTestActionContext({ - showQuickPick: async picks => picks[0], showInputBox: async () => { inputBoxShown = true; - return undefined; + return 'should-not-be-used'; }, }); const provider = createTestSubscriptionProvider({ - getAccounts: async () => [account], - getUnauthenticatedTenantsForAccount: async () => [discoveredTenant], - signIn: async tenant => { - signedInTenant = tenant; + signIn: async () => { + signInCalled = true; return true; }, }); - await signInToTenant(context, provider); + await signInToTenant(context, provider, async () => { + delegated = true; + }); - assert.strictEqual(signedInTenant, discoveredTenant); + assert.strictEqual(delegated, true); assert.strictEqual(inputBoxShown, false); + assert.strictEqual(signInCalled, false); + assert.strictEqual(getConfiguredAzureTenant(), undefined); }); - test('prompts for tenant when no account exists for tenant discovery', async () => { + test('falls back to manual entry and persists the tenant when not signed in', async () => { let signedInTenantId: string | undefined; - let signedInTenantAccount: AzureAccount | undefined; - let quickPickShown = false; + let signedInAccount: AzureAccount | undefined; const context = createTestActionContext({ - showQuickPick: async picks => { - quickPickShown = true; - return picks[0]; - }, - showInputBox: async () => 'contoso.onmicrosoft.com', + showInputBox: async () => ' contoso.onmicrosoft.com ', }); const provider = createTestSubscriptionProvider({ - getAccounts: async () => { - throw new NotSignedInError(); - }, signIn: async tenant => { signedInTenantId = tenant?.tenantId; - signedInTenantAccount = tenant?.account; + signedInAccount = tenant?.account; return true; }, }); - await signInToTenant(context, provider); + await signInToTenant(context, provider, async () => { + throw new NotSignedInError(); + }); assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); - assert.strictEqual(signedInTenantAccount, undefined); - assert.strictEqual(quickPickShown, false); - assert.strictEqual(getManualSignInTenant(), 'contoso.onmicrosoft.com'); + assert.strictEqual(signedInAccount, undefined); + assert.strictEqual(getConfiguredAzureTenant(), 'contoso.onmicrosoft.com'); }); - test('prompts for tenant when discovery returns no tenants', async () => { - const account = createAccount(); - let signedInTenantId: string | undefined; - let quickPickShown = false; - + test('does not persist the tenant when the manual sign-in is not completed', async () => { const context = createTestActionContext({ - showQuickPick: async picks => { - quickPickShown = true; - return picks[0]; - }, showInputBox: async () => 'contoso.onmicrosoft.com', }); const provider = createTestSubscriptionProvider({ - getAccounts: async () => [account], - getUnauthenticatedTenantsForAccount: async () => [], - signIn: async tenant => { - signedInTenantId = tenant?.tenantId; - return true; - }, + signIn: async () => false, }); - await signInToTenant(context, provider); + await signInToTenant(context, provider, async () => { + throw new NotSignedInError(); + }); - assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); - assert.strictEqual(quickPickShown, false); - assert.strictEqual(getManualSignInTenant(), 'contoso.onmicrosoft.com'); + assert.strictEqual(getConfiguredAzureTenant(), undefined); }); - test('uses tenant domain as quick pick label when display name is missing', async () => { - const account = createAccount(); - const discoveredTenant: AzureTenant = { - account, - tenantId: 'tenantId', - defaultDomain: 'contoso.onmicrosoft.com', - }; - let shownLabel: string | undefined; + test('rethrows non sign-in errors without prompting', async () => { + let inputBoxShown = false; const context = createTestActionContext({ - showQuickPick: async picks => { - shownLabel = picks[0].label; - return picks[0]; + showInputBox: async () => { + inputBoxShown = true; + return 'contoso.onmicrosoft.com'; }, - showInputBox: async () => undefined, }); const provider = createTestSubscriptionProvider({ - getAccounts: async () => [account], - getUnauthenticatedTenantsForAccount: async () => [discoveredTenant], signIn: async () => true, }); - await signInToTenant(context, provider); - - assert.strictEqual(shownLabel, 'contoso.onmicrosoft.com'); + await assert.rejects( + signInToTenant(context, provider, async () => { + throw new Error('boom'); + }), + /boom/, + ); + assert.strictEqual(inputBoxShown, false); + assert.strictEqual(getConfiguredAzureTenant(), undefined); }); +}); - test('uses tenant id as quick pick label when display name and domain are missing', async () => { - const account = createAccount(); - const discoveredTenant: AzureTenant = { - account, - tenantId: 'tenantId', - }; - let shownLabel: string | undefined; +suite('azureTenantSetting', () => { + teardown(async () => { + await setConfiguredAzureTenant(undefined); + }); - const context = createTestActionContext({ - showQuickPick: async picks => { - shownLabel = picks[0].label; - return picks[0]; - }, - showInputBox: async () => undefined, - }); - const provider = createTestSubscriptionProvider({ - getAccounts: async () => [account], - getUnauthenticatedTenantsForAccount: async () => [discoveredTenant], - signIn: async () => true, - }); + test('normalizeConfiguredTenant trims and treats blank as undefined', () => { + assert.strictEqual(normalizeConfiguredTenant(' contoso '), 'contoso'); + assert.strictEqual(normalizeConfiguredTenant(' '), undefined); + assert.strictEqual(normalizeConfiguredTenant(''), undefined); + assert.strictEqual(normalizeConfiguredTenant(undefined), undefined); + }); - await signInToTenant(context, provider); + test('setConfiguredAzureTenant round-trips through configuration', async () => { + await setConfiguredAzureTenant(' contoso.onmicrosoft.com '); + assert.strictEqual(getConfiguredAzureTenant(), 'contoso.onmicrosoft.com'); - assert.strictEqual(shownLabel, 'tenantId'); + await setConfiguredAzureTenant(undefined); + assert.strictEqual(getConfiguredAzureTenant(), undefined); }); - test('resetManualSignInTenantForTests clears persisted tenant', async () => { - await setManualSignInTenant('contoso.onmicrosoft.com'); + test('getConfiguredTenantFallback injects a synthetic tenant only when configured', () => { + const account = createAccount(); - await resetManualSignInTenantForTests(); + assert.deepStrictEqual(getConfiguredTenantFallback(account, 'contoso.onmicrosoft.com'), { + tenantId: 'contoso.onmicrosoft.com', + displayName: 'contoso.onmicrosoft.com', + account, + }); + assert.strictEqual(getConfiguredTenantFallback(account, ' '), undefined); + assert.strictEqual(getConfiguredTenantFallback(account, undefined), undefined); + }); + + test('configured tenant fallback renders in the Tenants view without throwing', () => { + const account = createAccount(); + const fallback = getConfiguredTenantFallback(account, 'contoso.onmicrosoft.com'); + assert.ok(fallback); - assert.strictEqual(getManualSignInTenant(), undefined); + // The Accounts & Tenants view builds a TenantTreeItem for every tenant, and that + // constructor requires a non-null displayName. A synthetic fallback without one would + // throw and collapse the whole view to empty. + assert.doesNotThrow(() => new TenantTreeItem(fallback, account)); }); }); @@ -181,31 +159,19 @@ function createAccount(): AzureAccount { } function createTestActionContext(options: { - showQuickPick: (picks: IAzureQuickPickItem[]) => Promise>; - showInputBox: () => Promise; + showInputBox: () => Promise; }): IActionContext { return { ui: { - showQuickPick: options.showQuickPick, showInputBox: options.showInputBox, }, } as unknown as IActionContext; } function createTestSubscriptionProvider(options: { - getAccounts?: () => Promise; - getUnauthenticatedTenantsForAccount?: (account: AzureAccount) => Promise; signIn: (tenant?: Partial) => Promise; }): AzureSubscriptionProvider { - const onRefreshSuggested: vscode.Event = (_listener, _thisArgs, _disposables) => ({ dispose: () => { /* no-op */ } }); - return { - onRefreshSuggested, - getAccounts: options.getAccounts ?? (async () => []), - getUnauthenticatedTenantsForAccount: options.getUnauthenticatedTenantsForAccount ?? (async () => []), signIn: options.signIn, - getAvailableSubscriptions: async () => [], - getTenantsForAccount: async () => [], - getSubscriptionsForTenant: async () => [], - }; + } as unknown as AzureSubscriptionProvider; }