Skip to content
Open
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,12 @@
{
"title": "Azure",
"properties": {
"azure.tenant": {
"type": "string",
"description": "%azure.tenant%",
"default": "",
"scope": "machine"
},
"azureResourceGroups.enableOutputTimestamps": {
"type": "boolean",
"description": "%azureResourceGroups.enableOutputTimestamps%",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
54 changes: 54 additions & 0 deletions src/commands/accounts/signInToTenant.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make the changes in the auth package in microsoft/vscode-azuretools rather than duplicating the code there. Potentially we add a tenant or tenant ID arg to that function? @alexweininger thoughts?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CoffeeKanzler thanks for opening the PR!

getPicks here duplicates the auth package's existing picker, can we instead just delegate to the auth package's signInToTenant(provider) and only fall back to the manual tenant input box when it throws NotSignedInError? That avoids the duplication and any auth-package changes. Also, do we actually need the manualSignInTenant persistence + getSubscriptionContext override?

For example: main...alexweininger/sign-in-to-tenant-manual-entry

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to refactor the picker to delegate to the auth package's signInToTenant(provider) with an input-box fallback on NotSignedInError, @alexweininger.

On whether we still need the manualSignInTenant persistence + getSubscriptionContext override: I tested exactly this. The manual sign-in itself succeeds, but resources never populate afterward.

The reason: subscription enumeration goes through getSubscriptionContext({ account, tenantId: undefined }) → listTenants(), and with tenantId undefined the silent token request hits the common/organizations
endpoint. So no subscriptions come back and the tree stays empty. The override re-injects the signed-in tenant into that account-level context, which scopes the silent token correctly and makes resources load.

It has to be persisted (not just in-memory) because the same enumeration re-runs on every reload.

@bwateratmsft — to your MSAL-stickiness question, that's what I was hoping too, but in practice the broker is only sticky for the interactive sign-in, not for the silent enumeration token. Without the persisted
tenant, a reload gives an empty tree.

So my proposal: Refactor the delegation (drop the duplicated picker) and keep the tenant persistence + context override (necessary for resources to actually load).

If you'd rather that re-scoping live in the auth package instead like e.g. a "default/sticky tenant" the provider applies to account-level requests then I'd need to further study the auth package after I am back.

Heads up on timing: I'm traveling in about an hour and won't be back until Friday at the earliest, so apologies in advance for the slow follow-up. I'll pick this up as soon as I'm back.

@alexweininger alexweininger Jun 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry to go back and forth on it, but now that we know we need to persist that value, then I think it's best kept as a setting like your original design.

  1. Drop the manualSignInTenant globalState, use an advanced setting instead.
  2. Move the discovery fix to getTenantsForAccount (we already override it, and it gets the account): when ARM /tenants returns empty/throws for a conditional-access user, inject AzureTenant { tenantId: <setting>, account }. The normal per-tenant flow then lists subscriptions via that tenant's session, so we can drop the getSubscriptionContext override entirely.
  3. Keep using the setting for the initial signIn({ tenantId }).

summary: one setting + the existing public override, no globalState, no internal coupling. Clients consume our discovered subscriptions via the Resources API, so this covers the whole extension family too.

Original file line number Diff line number Diff line change
@@ -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 <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<void> = signInToTenantFromAccounts,
): Promise<void> {
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);
}
}
Comment on lines +44 to +54
6 changes: 3 additions & 3 deletions src/commands/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
27 changes: 26 additions & 1 deletion src/services/VSCodeAzureSubscriptionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -15,7 +16,31 @@ import { isTenantFilteredOut } from '../utils/tenantSelection';
*/
class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider {
public override async getTenantsForAccount(account: AzureAccount, options?: GetTenantsForAccountOptions): Promise<AzureTenant[]> {
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
Expand Down
41 changes: 41 additions & 0 deletions src/utils/azureTenantSetting.ts
Original file line number Diff line number Diff line change
@@ -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<string>(azureTenantSettingKey));
}

export async function setConfiguredAzureTenant(tenant: string | undefined): Promise<void> {
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;
}
177 changes: 177 additions & 0 deletions test/signInToTenant.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}): IActionContext {
return {
ui: {
showInputBox: options.showInputBox,
},
} as unknown as IActionContext;
}

function createTestSubscriptionProvider(options: {
signIn: (tenant?: Partial<TenantIdAndAccount>) => Promise<boolean>;
}): AzureSubscriptionProvider {
return {
signIn: options.signIn,
} as unknown as AzureSubscriptionProvider;
}