Skip to content
82 changes: 82 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,82 @@
/*---------------------------------------------------------------------------------------------
* 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 { localize } from '../../utils/localize';

type ManualTenantSignIn = Partial<TenantIdAndAccount> & Pick<TenantIdAndAccount, 'tenantId'>;
type TenantSignIn = TenantIdAndAccount | ManualTenantSignIn;
type ManualTenantSubscriptionProvider = Omit<AzureSubscriptionProvider, 'signIn'> & {
signIn(tenant?: Partial<TenantIdAndAccount>, options?: SignInOptions): Promise<boolean>;
};

export async function signInToTenant(context: IActionContext, subscriptionProvider: ManualTenantSubscriptionProvider, account?: AzureAccount): Promise<void> {
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<TenantPick | undefined> {
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;
}
}

const tenant = await promptForTenantId(context);
return tenant ? { tenant, isManual: true } : undefined;
}

async function getPicks(subscriptionProvider: AzureSubscriptionProvider, account?: AzureAccount): Promise<IAzureQuickPickItem<TenantIdAndAccount>[]> {
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 ?? tenant.defaultDomain ?? tenant.tenantId,
description: `${tenant.tenantId}${isDuplicate(tenant.tenantId) ? ` (${tenant.account.label})` : ''}`,
detail: tenant.defaultDomain,
data: tenant,
}));
}

async function promptForTenantId(context: IActionContext): Promise<ManualTenantSignIn | undefined> {
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;
}
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
22 changes: 15 additions & 7 deletions src/services/VSCodeAzureSubscriptionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,9 +30,16 @@ class ResourceGroupsSubscriptionProvider extends VSCodeAzureSubscriptionProvider
return filtered;
}

return tenants;
}
}
return tenants;
}

protected override getSubscriptionContext(tenant: Partial<TenantIdAndAccount>) {
return super.getSubscriptionContext({
...tenant,
tenantId: getTenantIdForAuthentication(tenant.tenantId),
});
}
}

let vscodeAzureSubscriptionProvider: VSCodeAzureSubscriptionProvider | undefined;

Expand Down
46 changes: 46 additions & 0 deletions src/utils/manualSignInTenant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
Comment thread
CoffeeKanzler marked this conversation as resolved.
Outdated

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<string>(ManualSignInTenantKey));
}

export async function setManualSignInTenant(tenant: string): Promise<void> {
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<void> {
testingManualSignInTenant = undefined;
if (ext.context) {
await ext.context.globalState.update(ManualSignInTenantKey, undefined);
}
}
183 changes: 183 additions & 0 deletions test/signInToTenant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
Comment on lines +1 to +4

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';
import { getManualSignInTenant, resetManualSignInTenantForTests, setManualSignInTenant } from '../src/utils/manualSignInTenant';

suite('signInToTenant', () => {
setup(async () => {
await resetManualSignInTenantForTests();
});

teardown(async () => {
await resetManualSignInTenantForTests();
});

test('signs in to selected discovered tenant', async () => {
const account = createAccount();
const discoveredTenant: AzureTenant = {
account,
tenantId: 'tenantId',
displayName: 'Tenant',
};
let signedInTenant: Partial<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 signedInTenantAccount: AzureAccount | 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;
signedInTenantAccount = tenant?.account;
return true;
},
});

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 {
return {
id: 'accountId',
label: 'Account',
environment: getConfiguredAzureEnv(),
};
}

function createTestActionContext(options: {
showQuickPick: (picks: IAzureQuickPickItem<TenantIdAndAccount>[]) => Promise<IAzureQuickPickItem<TenantIdAndAccount>>;
showInputBox: () => Promise<string | undefined>;
}): IActionContext {
return {
ui: {
showQuickPick: options.showQuickPick,
showInputBox: options.showInputBox,
},
} as unknown as IActionContext;
}

function createTestSubscriptionProvider(options: {
getAccounts?: () => Promise<AzureAccount[]>;
getUnauthenticatedTenantsForAccount?: (account: AzureAccount) => Promise<AzureTenant[]>;
signIn: (tenant?: Partial<TenantIdAndAccount>) => Promise<boolean>;
}): AzureSubscriptionProvider {
const onRefreshSuggested: vscode.Event<RefreshSuggestedEvent> = (_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 () => [],
};
}