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 new file mode 100644 index 000000000..f2c144dd7 --- /dev/null +++ b/src/commands/accounts/signInToTenant.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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'; + +/** + * 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 { + await signInFromAccounts(subscriptionProvider); + return; + } catch (error) { + if (!isNotSignedInError(error)) { + throw error; + } + } + + 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.'), + })).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/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/services/VSCodeAzureSubscriptionProvider.ts b/src/services/VSCodeAzureSubscriptionProvider.ts index 71f09e15f..0b9f6b0b7 100644 --- a/src/services/VSCodeAzureSubscriptionProvider.ts +++ b/src/services/VSCodeAzureSubscriptionProvider.ts @@ -6,6 +6,7 @@ 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'; /** @@ -15,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 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/test/signInToTenant.test.ts b/test/signInToTenant.test.ts new file mode 100644 index 000000000..bb778d110 --- /dev/null +++ b/test/signInToTenant.test.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +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 { TenantTreeItem } from '../src/tree/tenants/TenantTreeItem'; +import { getConfiguredAzureTenant, getConfiguredTenantFallback, normalizeConfiguredTenant, setConfiguredAzureTenant } from '../src/utils/azureTenantSetting'; + +suite('signInToTenant', () => { + teardown(async () => { + await setConfiguredAzureTenant(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({ + showInputBox: async () => { + inputBoxShown = true; + return 'should-not-be-used'; + }, + }); + const provider = createTestSubscriptionProvider({ + signIn: async () => { + signInCalled = true; + return true; + }, + }); + + await signInToTenant(context, provider, async () => { + delegated = true; + }); + + assert.strictEqual(delegated, true); + assert.strictEqual(inputBoxShown, false); + assert.strictEqual(signInCalled, false); + assert.strictEqual(getConfiguredAzureTenant(), undefined); + }); + + test('falls back to manual entry and persists the tenant when not signed in', async () => { + let signedInTenantId: string | undefined; + let signedInAccount: AzureAccount | undefined; + + const context = createTestActionContext({ + showInputBox: async () => ' contoso.onmicrosoft.com ', + }); + const provider = createTestSubscriptionProvider({ + signIn: async tenant => { + signedInTenantId = tenant?.tenantId; + signedInAccount = tenant?.account; + return true; + }, + }); + + await signInToTenant(context, provider, async () => { + throw new NotSignedInError(); + }); + + assert.strictEqual(signedInTenantId, 'contoso.onmicrosoft.com'); + assert.strictEqual(signedInAccount, undefined); + assert.strictEqual(getConfiguredAzureTenant(), 'contoso.onmicrosoft.com'); + }); + + test('does not persist the tenant when the manual sign-in is not completed', async () => { + const context = createTestActionContext({ + showInputBox: async () => 'contoso.onmicrosoft.com', + }); + const provider = createTestSubscriptionProvider({ + signIn: async () => false, + }); + + await signInToTenant(context, provider, async () => { + throw new NotSignedInError(); + }); + + assert.strictEqual(getConfiguredAzureTenant(), undefined); + }); + + test('rethrows non sign-in errors without prompting', async () => { + let inputBoxShown = false; + + const context = createTestActionContext({ + showInputBox: async () => { + inputBoxShown = true; + return 'contoso.onmicrosoft.com'; + }, + }); + const provider = createTestSubscriptionProvider({ + signIn: async () => true, + }); + + await assert.rejects( + signInToTenant(context, provider, async () => { + throw new Error('boom'); + }), + /boom/, + ); + assert.strictEqual(inputBoxShown, false); + assert.strictEqual(getConfiguredAzureTenant(), undefined); + }); +}); + +suite('azureTenantSetting', () => { + teardown(async () => { + await setConfiguredAzureTenant(undefined); + }); + + 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); + }); + + test('setConfiguredAzureTenant round-trips through configuration', async () => { + await setConfiguredAzureTenant(' contoso.onmicrosoft.com '); + assert.strictEqual(getConfiguredAzureTenant(), 'contoso.onmicrosoft.com'); + + await setConfiguredAzureTenant(undefined); + assert.strictEqual(getConfiguredAzureTenant(), undefined); + }); + + test('getConfiguredTenantFallback injects a synthetic tenant only when configured', () => { + const account = createAccount(); + + 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); + + // 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)); + }); +}); + +function createAccount(): AzureAccount { + return { + id: 'accountId', + label: 'Account', + environment: getConfiguredAzureEnv(), + }; +} + +function createTestActionContext(options: { + showInputBox: () => Promise; +}): IActionContext { + return { + ui: { + showInputBox: options.showInputBox, + }, + } as unknown as IActionContext; +} + +function createTestSubscriptionProvider(options: { + signIn: (tenant?: Partial) => Promise; +}): AzureSubscriptionProvider { + return { + signIn: options.signIn, + } as unknown as AzureSubscriptionProvider; +}