diff --git a/package-lock.json b/package-lock.json index afabb18..089364c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "vscode-apimanagement", - "version": "1.0.8", + "version": "1.0.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-apimanagement", - "version": "1.0.8", + "version": "1.0.9", "dependencies": { "@azure/arm-apimanagement": "^9.2.0", "@azure/arm-appservice": "^15.0.0", "@azure/arm-resources": "^4.0.0", + "@azure/arm-resources-subscriptions": "^2.1.0", "@azure/ms-rest-nodeauth": "^3.1.1", "@microsoft/vscode-azext-azureutils": "^3.1.2", "@microsoft/vscode-azext-utils": "^2.5.12", diff --git a/package.json b/package.json index 98d31fa..fa307b3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vscode-apimanagement", "displayName": "Azure API Management", "description": "An Azure API Management extension for Visual Studio Code.", - "version": "1.0.9", + "version": "1.0.10", "publisher": "ms-azuretools", "icon": "resources/apim-icon-newone.png", "aiKey": "AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217", @@ -190,13 +190,19 @@ "title": "%azureApiManagement.openInPortal%", "category": "Azure API Management" }, + { + "command": "azureApiManagement.selectTenant", + "title": "%azureApiManagement.selectTenant%", + "category": "Azure API Management" + }, { "command": "azureApiManagement.selectSubscriptions", - "title": "Select Subscription...", + "title": "%azureApiManagement.selectSubscriptions%", "icon": { "light": "resources/light/filter.svg", "dark": "resources/dark/filter.svg" - } + }, + "category": "Azure API Management" }, { "command": "azureApiManagement.Refresh", @@ -782,6 +788,27 @@ "type": "boolean", "default": false, "description": "%azureApiManagement.advancedPolicyAuthoringExperience%" + }, + "azureApiManagement.selectedSubscriptions": { + "type": "array", + "description": "%azureApiManagement.selectAzureSubscriptions%", + "items": { + "type": "string" + } + }, + "azureApiManagement.selectedTenant": { + "type": "object", + "description": "%azureApiManagement.selectAzureTenant%", + "properties": { + "name": { + "type": "string", + "description": "%azureApiManagement.selectAzureTenant.tenantName%" + }, + "id": { + "type": "string", + "description": "%azureApiManagement.selectAzureTenant.tenantId%" + } + } } } } @@ -834,6 +861,7 @@ "@azure/arm-apimanagement": "^9.2.0", "@azure/arm-appservice": "^15.0.0", "@azure/arm-resources": "^4.0.0", + "@azure/arm-resources-subscriptions": "^2.1.0", "@azure/ms-rest-nodeauth": "^3.1.1", "@microsoft/vscode-azext-azureutils": "^3.1.2", "@microsoft/vscode-azext-utils": "^2.5.12", @@ -857,10 +885,9 @@ "xregexp": "^4.3.0" }, "extensionDependencies": [ - "ms-vscode.azure-account", "humao.rest-client" ], "overrides": { "fsevents": "~2.3.2" } -} +} \ No newline at end of file diff --git a/package.nls.json b/package.nls.json index 9edc71c..fae3e79 100644 --- a/package.nls.json +++ b/package.nls.json @@ -27,6 +27,10 @@ "azureApiManagement.openExtensionWorkspaceFolder": "Open extension workspace folder", "azureApiManagement.initializeExtensionWorkspaceFolder": "Initialize extension workspace folder", "azureApiManagement.advancedPolicyAuthoringExperience": "(Experimental Feature) Enables advanced policy authoring experience.", + "azureApiManagement.selectAzureSubscriptions": "Selected Azure subscriptions", + "azureApiManagement.selectAzureTenant": "Selected Azure Tenant", + "azureApiManagement.selectAzureTenant.tenantName": "tenant name", + "azureApiManagement.selectAzureTenant.tennatId": "tenant id", "azureApiManagement.extractService": "Extract Service", "azureApiManagement.extractApi": "Extract API", "azureApiManagement.importFunctionApp": "Import from Azure Functions", @@ -56,5 +60,7 @@ "azureApiManagement.deleteAuthorizationAccessPolicy": "Delete Access Policy", "azureApiManagement.showArmAuthorizationProvider": "Edit Authorization Provider", "azureApiManagement.showArmAuthorization": "Edit Authorization", - "azureApiManagement.showArmAuthorizationAccessPolicy": "Edit Authorization Access Policy" + "azureApiManagement.showArmAuthorizationAccessPolicy": "Edit Authorization Access Policy", + "azureApiManagement.selectTenant": "Select Tenant...", + "azureApiManagement.selectSubscriptions": "Select Subscription..." } \ No newline at end of file diff --git a/src/azure/azureLogin/authTypes.ts b/src/azure/azureLogin/authTypes.ts new file mode 100644 index 0000000..033c82d --- /dev/null +++ b/src/azure/azureLogin/authTypes.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { AuthenticationSession, Event } from "vscode"; +import { GeneralUtils } from "../../utils/generalUtils"; + +export enum SignInStatus { + Initializing = 'Initializing', + SigningIn = 'SigningIn', + SignedIn = 'SignedIn', + SignedOut = 'SignedOut', +} + +export enum SelectionType { + Filtered, + All, + AllIfNoFilters, +} + +export interface SubscriptionFilter { + tenantId: string; + subscriptionId: string; +} + +export type TokenInfo = { + token: string; + expiry: Date; +}; + +export type AzureAuthenticationSession = AuthenticationSession & { + tenantId: string; +}; + +export type Tenant = { + name: string; + id: string; +}; + +export type GetAuthSessionOptions = { + applicationClientId?: string; + scopes?: string[]; +}; + +export type AzureSessionProvider = { + signIn(): Promise; + signInStatus: SignInStatus; + availableTenants: Tenant[]; + selectedTenant: Tenant | null; + signInStatusChangeEvent: Event; + getAuthSession(options?: GetAuthSessionOptions): Promise>; + dispose(): void; +}; + +export type ReadyAzureSessionProvider = AzureSessionProvider & { + signInStatus: SignInStatus.SignedIn; + selectedTenant: Tenant; +}; diff --git a/src/azure/azureLogin/azureAccount.ts b/src/azure/azureLogin/azureAccount.ts new file mode 100644 index 0000000..dcb0caf --- /dev/null +++ b/src/azure/azureLogin/azureAccount.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { SubscriptionClient, TenantIdDescription } from "@azure/arm-resources-subscriptions"; +import { TokenCredential } from "@azure/core-auth"; +import { AuthenticationSession, QuickPickItem, Uri, env, window, workspace, ConfigurationTarget } from "vscode"; +import { UiStrings } from "../../uiStrings"; +import { GeneralUtils } from "../../utils/generalUtils"; +import { SelectionType, SignInStatus, SubscriptionFilter, Tenant } from "./authTypes"; +import { AzureAuth } from "./azureAuth"; +import { AzureSessionProviderHelper } from "./azureSessionProvider"; +import { AzureSubscriptionHelper } from "./subscriptions"; +import { AzureAccountUrl, extensionPrefix } from "../../constants"; +import { AzureLoginConstantString } from "./constants"; +export namespace AzureAccount { + export async function signInToAzure(): Promise { + await AzureSessionProviderHelper.getSessionProvider().signIn(); + } + + export function getSelectedTenant(): Tenant | undefined { + return workspace.getConfiguration(extensionPrefix).get(AzureLoginConstantString.selectedTenant); + } + + export async function updateSelectedTenant(value?: Tenant): Promise { + await workspace.getConfiguration(extensionPrefix).update(AzureLoginConstantString.selectedTenant, value, ConfigurationTarget.Global, true); + } + + export async function selectTenant(): Promise { + const sessionProvider = AzureSessionProviderHelper.getSessionProvider(); + if (sessionProvider.signInStatus !== SignInStatus.SignedIn) { + window.showInformationMessage(UiStrings.SelectTenantBeforeSignIn); + return; + } + + if (sessionProvider.availableTenants.length === 1) { + sessionProvider.selectedTenant = sessionProvider.availableTenants[0]; + + // If this tenant wasn't previously selected, it was probably because it wasn't immediately + // accessible (the user's current token didn't have access to it). Calling getAuthSession + // will prompt the user to re-authenticate if necessary. + const sessionResult = await sessionProvider.getAuthSession(); + if (GeneralUtils.failed(sessionResult)) { + window.showErrorMessage(sessionResult.error); + } + + return; + } + + const selectedTenant = await AzureAuth.quickPickTenant(sessionProvider.availableTenants); + if (!selectedTenant) { + window.showInformationMessage(UiStrings.NoTenantSelected); + return; + } + + sessionProvider.selectedTenant = selectedTenant; + await updateSelectedTenant(selectedTenant); + } + + type SubscriptionQuickPickItem = QuickPickItem & { subscription: SubscriptionFilter }; + + export async function selectSubscriptions(): Promise { + const sessionProvider = await AzureAuth.getReadySessionProvider(); + if (GeneralUtils.failed(sessionProvider)) { + window.showErrorMessage(sessionProvider.error); + return; + } + + const allSubscriptions = await AzureSubscriptionHelper.getSubscriptions(sessionProvider.result, SelectionType.All); + if (GeneralUtils.failed(allSubscriptions)) { + window.showErrorMessage(allSubscriptions.error); + return; + } + + if (allSubscriptions.result.length === 0) { + const noSubscriptionsFound = UiStrings.NoSubscriptionsFoundAndSetup; + const setupAccount = UiStrings.SetUpAzureAccount; + const response = await window.showInformationMessage(noSubscriptionsFound, setupAccount); + if (response === setupAccount) { + env.openExternal(Uri.parse(AzureAccountUrl.azureMicrosoftLink)); + } + + return; + } + + const session = await sessionProvider.result.getAuthSession(); + if (GeneralUtils.failed(session)) { + window.showErrorMessage(session.error); + return; + } + + const filteredSubscriptions = await AzureSubscriptionHelper.getFilteredSubscriptions(); + + const subscriptionsInCurrentTenant = filteredSubscriptions.filter( + (sub) => sub.tenantId === session.result.tenantId, + ); + const subscriptionsInOtherTenants = filteredSubscriptions.filter((sub) => sub.tenantId !== session.result.tenantId); + + const quickPickItems: SubscriptionQuickPickItem[] = allSubscriptions.result.map((sub) => { + return { + label: sub.displayName || "", + description: sub.subscriptionId, + picked: subscriptionsInCurrentTenant.some((filtered) => filtered.subscriptionId === sub.subscriptionId), + subscription: { + subscriptionId: sub.subscriptionId || "", + tenantId: sub.tenantId || "", + }, + }; + }); + + const selectedItems = await window.showQuickPick(quickPickItems, { + canPickMany: true, + placeHolder: UiStrings.SelectSubscription, + }); + + if (!selectedItems) { + return; + } + + const newFilteredSubscriptions = [ + ...selectedItems.map((item) => item.subscription), + ...subscriptionsInOtherTenants, // Retain filters in any other tenants. + ]; + + await AzureSubscriptionHelper.setFilteredSubscriptions(newFilteredSubscriptions); + } + export async function getTenants(session: AuthenticationSession): Promise> { + const armEndpoint = AzureAuth.getConfiguredAzureEnv().resourceManagerEndpointUrl; + const credential: TokenCredential = { + getToken: async () => { + return { token: session.accessToken, expiresOnTimestamp: 0 }; + }, + }; + const subscriptionClient = new SubscriptionClient(credential, { endpoint: armEndpoint }); + + const tenantsResult = await AzureAuth.listAll(subscriptionClient.tenants.list()); + return GeneralUtils.errMap(tenantsResult, (t) => t.filter(isTenant).map((t) => ({ name: t.displayName, id: t.tenantId }))); + } + export function findTenant(tenants: Tenant[], tenantId: string): Tenant | null { + return tenants.find((t) => t.id === tenantId) || null; + } + export function isTenant(tenant: TenantIdDescription): tenant is { tenantId: string; displayName: string } { + return tenant.tenantId !== undefined && tenant.displayName !== undefined; + } + export function getIdString(tenants: Tenant[]): string { + return tenants + .map((t) => t.id) + .sort() + .join(","); + } +} \ No newline at end of file diff --git a/src/azure/azureLogin/azureAuth.ts b/src/azure/azureLogin/azureAuth.ts new file mode 100644 index 0000000..3c2301b --- /dev/null +++ b/src/azure/azureLogin/azureAuth.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { TokenCredential } from "@azure/core-auth"; +import { PagedAsyncIterableIterator } from "@azure/core-paging"; +import { Environment, EnvironmentParameters } from "@azure/ms-rest-azure-env"; +import * as vscode from "vscode"; +import { UiStrings } from "../../uiStrings"; +import { GeneralUtils } from "../../utils/generalUtils"; +import { AzureSessionProvider, GetAuthSessionOptions, ReadyAzureSessionProvider, SignInStatus, Tenant } from "./authTypes"; +import { AzureSessionProviderHelper } from "./azureSessionProvider"; +import { AzureEnvType, AzureLoginConstantString } from "./constants"; +export namespace AzureAuth { + export function getEnvironment(): Environment { + return getConfiguredAzureEnv(); + } + + export function getCredential(sessionProvider: ReadyAzureSessionProvider): TokenCredential { + return { + getToken: async () => { + const session = await sessionProvider.getAuthSession(); + if (GeneralUtils.failed(session)) { + throw new Error(vscode.l10n.t(UiStrings.NoMSAuthSessionFound, session.error)); + } + + return { token: session.result.accessToken, expiresOnTimestamp: 0 }; + }, + }; + } + + export function getDefaultScope(endpointUrl: string): string { + // Endpoint URL is that of the audience, e.g. for ARM in the public cloud + // it would be "https://management.azure.com". + return endpointUrl.endsWith("/") ? `${endpointUrl}.default` : `${endpointUrl}/.default`; + } + + export async function quickPickTenant(tenants: Tenant[]): Promise { + const items: (vscode.QuickPickItem & { tenant: Tenant })[] = tenants.map((t) => ({ + label: `${t.name} (${t.id})`, + tenant: t, + })); + const result = await vscode.window.showQuickPick(items, { + placeHolder: UiStrings.SelectATenant, + }); + return result ? result.tenant : undefined; + } + + export async function getReadySessionProvider(): Promise> { + const sessionProvider = AzureSessionProviderHelper.getSessionProvider(); + if (AzureAuth.isReady(sessionProvider)) { + return { succeeded: true, result: sessionProvider }; + } + + switch (sessionProvider.signInStatus) { + case SignInStatus.Initializing: + case SignInStatus.SigningIn: + await waitForSignIn(sessionProvider); + break; + case SignInStatus.SignedOut: + await sessionProvider.signIn(); + break; + case SignInStatus.SignedIn: + break; + } + + // Get a session, which will prompt the user to select a tenant if necessary. + const session = await sessionProvider.getAuthSession(); + if (GeneralUtils.failed(session)) { + return { succeeded: false, error: `Failed to get authentication session: ${session.error}` }; + } + + if (!AzureAuth.isReady(sessionProvider)) { + return { succeeded: false, error: "Not signed in." }; + } + return { succeeded: true, result: sessionProvider }; + } + + async function waitForSignIn(sessionProvider: AzureSessionProvider): Promise { + const options: vscode.ProgressOptions = { + location: vscode.ProgressLocation.Notification, + title: UiStrings.WaitForSignIn, + cancellable: true, + }; + + await vscode.window.withProgress(options, (_, token) => { + let listener: vscode.Disposable | undefined; + token.onCancellationRequested(listener?.dispose()); + return new Promise((resolve) => { + listener = sessionProvider.signInStatusChangeEvent((status) => { + if (status === SignInStatus.SignedIn) { + listener?.dispose(); + resolve(undefined); + } + }); + }); + }); + } + + export function getConfiguredAzureEnv(): Environment { + // See: + // https://github.com/microsoft/vscode/blob/eac16e9b63a11885b538db3e0b533a02a2fb8143/extensions/microsoft-authentication/package.json#L40-L99 + const section = "microsoft-sovereign-cloud"; + const settingName = AzureLoginConstantString.environment; + const authProviderConfig = vscode.workspace.getConfiguration(section); + const environmentSettingValue = authProviderConfig.get(settingName); + + if (environmentSettingValue === AzureEnvType.ChinaCloud) { + return Environment.ChinaCloud; + } else if (environmentSettingValue === AzureEnvType.USGovernment) { + return Environment.USGovernment; + } else if (environmentSettingValue === AzureEnvType.custom) { + const customCloud = authProviderConfig.get(AzureEnvType.customEnvironment); + if (customCloud) { + return new Environment(customCloud); + } + + throw new Error(vscode.l10n.t(UiStrings.CustomCloudChoiseNotConfigured, section, settingName)); + } + + return Environment.get(Environment.AzureCloud.name); + } + + export async function listAll(iterator: PagedAsyncIterableIterator): Promise> { + const result: T[] = []; + try { + for await (const page of iterator.byPage()) { + result.push(...page); + } + return { succeeded: true, result }; + } catch (e) { + return { succeeded: false, error: vscode.l10n.t(UiStrings.FailedToListGroup, GeneralUtils.getErrorMessage(e)) }; + } + } + + export function getScopes(tenantId: string | null, options: GetAuthSessionOptions): string[] { + const defaultScopes = options.scopes || [AzureAuth.getDefaultScope(AzureAuth.getConfiguredAzureEnv().resourceManagerEndpointUrl)]; + const tenantScopes = tenantId ? [`VSCODE_TENANT:${tenantId}`] : []; + const clientIdScopes = options.applicationClientId ? [`VSCODE_CLIENT_ID:${options.applicationClientId}`] : []; + return [...defaultScopes, ...tenantScopes, ...clientIdScopes]; + } + + type AuthProviderId = "microsoft" | "microsoft-sovereign-cloud"; + + export function getConfiguredAuthProviderId(): AuthProviderId { + return AzureAuth.getConfiguredAzureEnv().name === Environment.AzureCloud.name ? "microsoft" : "microsoft-sovereign-cloud"; + } + + export function isReady(provider: AzureSessionProvider): provider is ReadyAzureSessionProvider { + return provider.signInStatus === SignInStatus.SignedIn && provider.selectedTenant !== null; + } +} diff --git a/src/azure/azureLogin/azureSessionProvider.ts b/src/azure/azureLogin/azureSessionProvider.ts new file mode 100644 index 0000000..c501c4f --- /dev/null +++ b/src/azure/azureLogin/azureSessionProvider.ts @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + AuthenticationGetSessionOptions, + AuthenticationSession, + Event, + EventEmitter, + ExtensionContext, + Disposable as VsCodeDisposable, + authentication, + l10n +} from "vscode"; +import { UiStrings } from "../../uiStrings"; +import { GeneralUtils } from "../../utils/generalUtils"; +import { AzureAuthenticationSession, AzureSessionProvider, GetAuthSessionOptions, SignInStatus, Tenant } from "./authTypes"; +import { AzureAccount } from "./azureAccount"; +import { AzureAuth } from "./azureAuth"; +import { AzureLoginConstantString } from "./constants"; + +enum AuthScenario { + Initialization, + SignIn, + GetSession, +} + +export namespace AzureSessionProviderHelper { + + let sessionProvider: AzureSessionProvider; + + export function activateAzureSessionProvider(context: ExtensionContext) { + sessionProvider = new AzureSessionProviderImpl(); + context.subscriptions.push(sessionProvider); + } + + export function getSessionProvider(): AzureSessionProvider { + return sessionProvider; + } + + class AzureSessionProviderImpl extends VsCodeDisposable implements AzureSessionProvider { + private readonly initializePromise: Promise; + private handleSessionChanges: boolean = true; + private tenants: Tenant[] = []; + private selectedTenantValue: Tenant | null = null; + + public readonly onSignInStatusChangeEmitter = new EventEmitter(); + public signInStatusValue: SignInStatus = SignInStatus.Initializing; + + public constructor() { + const disposable = authentication.onDidChangeSessions(async (e) => { + // Ignore events for non-microsoft providers + if (e.provider.id !== AzureAuth.getConfiguredAuthProviderId()) { + return; + } + + // Ignore events that we triggered. + if (!this.handleSessionChanges) { + return; + } + + // Silently check authentication status and tenants + await this.signInAndUpdateTenants(AuthScenario.Initialization); + }); + + super(() => { + this.onSignInStatusChangeEmitter.dispose(); + disposable.dispose(); + }); + + this.initializePromise = this.initialize(); + } + + public get signInStatus(): SignInStatus { + return this.signInStatusValue; + } + + public get signInStatusChangeEvent(): Event { + return this.onSignInStatusChangeEmitter.event; + } + + public get availableTenants(): Tenant[] { + return [...this.tenants]; + } + + public get selectedTenant(): Tenant | null { + return this.selectedTenantValue; + } + + public set selectedTenant(tenant: Tenant | null) { + const isValid = tenant === null || this.tenants.some((t) => t.id === tenant.id); + const isChanged = this.selectedTenantValue !== tenant; + if (isValid && isChanged) { + this.selectedTenantValue = tenant; + this.onSignInStatusChangeEmitter.fire(this.signInStatusValue); + } + } + + private async initialize(): Promise { + await this.signInAndUpdateTenants(AuthScenario.Initialization); + } + + /** + * Sign in to Azure interactively, i.e. prompt the user to sign in even if they have an active session. + * This allows the user to choose a different account or tenant. + */ + public async signIn(): Promise { + await this.initializePromise; + + const newSignInStatus = SignInStatus.SigningIn; + if (newSignInStatus !== this.signInStatusValue) { + this.signInStatusValue = newSignInStatus; + this.onSignInStatusChangeEmitter.fire(this.signInStatusValue); + } + + await this.signInAndUpdateTenants(AuthScenario.SignIn); + } + + private async signInAndUpdateTenants(authScenario: AuthScenario): Promise { + // Initially, try to get a session using the 'organizations' tenant/authority: + // https://learn.microsoft.com/en-us/entra/identity-platform/msal-client-application-configuration#authority + // This allows the user to sign in to the Microsoft provider and list tenants, + // but the resulting session will not allow tenant-level operations. For that, + // we need to get a session for a specific tenant. + const orgTenantId = AzureLoginConstantString.organizations; + const scopes = AzureAuth.getScopes(orgTenantId, {}); + const getSessionResult = await this.getArmSession(orgTenantId, scopes, authScenario); + + // Get the tenants + const getTenantsResult = await GeneralUtils.bindAsync(getSessionResult, (session) => AzureAccount.getTenants(session)); + const newTenants = GeneralUtils.succeeded(getTenantsResult) ? getTenantsResult.result : []; + const tenantsChanged = AzureAccount.getIdString(newTenants) !== AzureAccount.getIdString(this.tenants); + + // Determine which tenant should be selected. We can't force the user to choose at this stage, + // so this can be null, and will be set when the user tries to get a session. + const newSelectedTenant = await this.getNewSelectedTenant(newTenants, this.selectedTenantValue, authScenario); + const selectedTenantChanged = newSelectedTenant?.id !== this.selectedTenantValue?.id; + + // Get the overall sign-in status. If the user has access to any tenants they are signed in. + const newSignInStatus = newTenants.length > 0 ? SignInStatus.SignedIn : SignInStatus.SignedOut; + const signInStatusChanged = newSignInStatus !== this.signInStatusValue; + + // Update the state and fire event if anything has changed. + this.selectedTenantValue = newSelectedTenant; + this.tenants = newTenants; + this.signInStatusValue = newSignInStatus; + if (signInStatusChanged || tenantsChanged || selectedTenantChanged) { + if (newSignInStatus === SignInStatus.SignedOut) { + await AzureAccount.updateSelectedTenant(); + } + this.onSignInStatusChangeEmitter.fire(this.signInStatusValue); + } + } + + /** + * Get the current Azure session, silently if possible. + * @returns The current Azure session, if available. If the user is not signed in, or there are no tenants, + * an error message is returned. + */ + public async getAuthSession(options?: GetAuthSessionOptions): Promise> { + await this.initializePromise; + if (this.signInStatusValue !== SignInStatus.SignedIn) { + return { succeeded: false, error: l10n.t(UiStrings.NotSignInStatus, this.signInStatusValue) }; + } + + if (this.tenants.length === 0) { + return { succeeded: false, error: UiStrings.NoTenantFound }; + } + + if (!this.selectedTenantValue) { + if (this.tenants.length > 1) { + const selectedTenant = await AzureAuth.quickPickTenant(this.tenants); + if (!selectedTenant) { + return { succeeded: false, error: UiStrings.NoTenantSelected }; + } + + this.selectedTenantValue = selectedTenant; + } else { + this.selectedTenantValue = this.tenants[0]; + } + } + + // Get a session for a specific tenant. + const tenantId = this.selectedTenantValue.id; + const scopes = AzureAuth.getScopes(tenantId, options || {}); + return await this.getArmSession(tenantId, scopes, AuthScenario.GetSession); + } + + private async getNewSelectedTenant( + newTenants: Tenant[], + currentSelectedTenant: Tenant | null, + authScenario: AuthScenario, + ): Promise { + // For sign-in we ignore the current selected tenant because the user must be able to change it. + // For all other scenarios, we prefer to retain the current selected tenant if it is still valid. + const ignoreCurrentSelection = authScenario === AuthScenario.SignIn; + if (!ignoreCurrentSelection && currentSelectedTenant !== null) { + const isCurrentSelectedTenantValid = newTenants.some((t) => t.id === currentSelectedTenant.id); + if (isCurrentSelectedTenantValid) { + return currentSelectedTenant; + } + } + + // For sign-in, if there are multiple tenants, we should prompt the user to select one. + if (authScenario === AuthScenario.SignIn && newTenants.length > 1) { + return null; + } + + // For all other (non-interactive) scenarios, see if we can determine a default tenant to use. + const defaultTenant = await this.getDefaultTenantId(newTenants); + return defaultTenant; + } + + private async getDefaultTenantId(tenants: Tenant[]): Promise { + if (tenants.length === 1) { + return tenants[0]; + } + + // It may be the case that the user has access to multiple tenants, but only has a valid token for one of them. + // This might happen if the user has signed in to one recently, but not the others. In this case, we would want + // to default to the tenant that the user has a valid token for. + // Use the 'Initialization' scenario to ensure this is silent (no user interaction). + const getSessionPromises = tenants.map((t) => + this.getArmSession(t.id, AzureAuth.getScopes(t.id, {}), AuthScenario.Initialization), + ); + const results = await Promise.all(getSessionPromises); + const accessibleTenants = results.filter(GeneralUtils.succeeded).map((r) => r.result); + if (accessibleTenants.length === 1) { + return AzureAccount.findTenant(tenants, accessibleTenants[0].tenantId); + } + const lastTenant = AzureAccount.getSelectedTenant(); + return lastTenant && accessibleTenants.some(item => item.tenantId === lastTenant.id) ? lastTenant : null; + } + + private async getArmSession( + tenantId: string, + scopes: string[], + authScenario: AuthScenario, + ): Promise> { + this.handleSessionChanges = false; + try { + let options: AuthenticationGetSessionOptions; + let silentFirst = false; + switch (authScenario) { + case AuthScenario.Initialization: + options = { createIfNone: false, clearSessionPreference: false, silent: true }; + break; + case AuthScenario.SignIn: + options = { createIfNone: true, clearSessionPreference: true, silent: false }; + break; + case AuthScenario.GetSession: + // the 'createIfNone' option cannot be used with 'silent', but really we want both + // flags here (i.e. create a session silently, but do create one if it doesn't exist). + // To allow this, we first try to get a session silently. + silentFirst = true; + options = { createIfNone: true, clearSessionPreference: false, silent: false }; + break; + } + + let session: AuthenticationSession | undefined; + if (silentFirst) { + // The 'silent' option is incompatible with most other options, so we completely replace the options object here. + session = await authentication.getSession(AzureAuth.getConfiguredAuthProviderId(), scopes, { silent: true }); + } + + if (!session) { + session = await authentication.getSession(AzureAuth.getConfiguredAuthProviderId(), scopes, options); + } + + if (!session) { + return { succeeded: false, error: UiStrings.NoAzureSessionFound }; + } + + return { succeeded: true, result: Object.assign(session, { tenantId }) }; + } catch (e) { + return { succeeded: false, error: l10n.t(UiStrings.FailedTo, GeneralUtils.getErrorMessage(e)) }; + } finally { + this.handleSessionChanges = true; + } + } + } + +} diff --git a/src/azure/azureLogin/constants.ts b/src/azure/azureLogin/constants.ts new file mode 100644 index 0000000..d0ff4d6 --- /dev/null +++ b/src/azure/azureLogin/constants.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +export const AzureLoginConstantString = { + organizations: "organizations", + environment: "environment", + selectedSubscriptions: "selectedSubscriptions", + selectedTenant: "selectedTenant", +} + +export const AzureEnvType = { + ChinaCloud: "ChinaCloud", + USGovernment: "USGovernment", + custom: "custom", + customEnvironment: "customEnvironment", +} \ No newline at end of file diff --git a/src/azure/azureLogin/subscriptions.ts b/src/azure/azureLogin/subscriptions.ts new file mode 100644 index 0000000..561f0c3 --- /dev/null +++ b/src/azure/azureLogin/subscriptions.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { Subscription, SubscriptionClient } from "@azure/arm-resources-subscriptions"; +import * as vscode from "vscode"; +import { GeneralUtils } from "../../utils/generalUtils"; +import { ReadyAzureSessionProvider, SelectionType, SubscriptionFilter } from "./authTypes"; +import { AzureAuth } from "./azureAuth"; +import { extensionPrefix } from "../../constants"; +import { AzureLoginConstantString } from "./constants"; + +export namespace AzureSubscriptionHelper { + const onFilteredSubscriptionsChangeEmitter = new vscode.EventEmitter(); + + export function getFilteredSubscriptionsChangeEvent() { + return onFilteredSubscriptionsChangeEmitter.event; + } + + export function getFilteredSubscriptions(): SubscriptionFilter[] { + try { + let values = vscode.workspace.getConfiguration(extensionPrefix).get(AzureLoginConstantString.selectedSubscriptions, []); + return values.map(asSubscriptionFilter).filter((v) => v !== null) as SubscriptionFilter[]; + } catch (e) { + return []; + } + } + + function asSubscriptionFilter(value: string): SubscriptionFilter | null { + try { + const parts = value.split("/"); + return { tenantId: parts[0], subscriptionId: parts[1] }; + } catch (e) { + return null; + } + } + + /** + * A subscription with the subscriptionId and displayName properties guaranteed to be defined. + */ + export type DefinedSubscription = Subscription & Required>; + + export async function getSubscriptions( + sessionProvider: ReadyAzureSessionProvider, + selectionType: SelectionType, + ): Promise> { + const client = getSubscriptionClient(sessionProvider); + const subsResult = await AzureAuth.listAll(client.subscriptions.list()); + return GeneralUtils.errMap(subsResult, (subs: any) => sortAndFilter(subs.filter(isDefinedSubscription), selectionType)); + } + + function sortAndFilter(subscriptions: DefinedSubscription[], selectionType: SelectionType): DefinedSubscription[] { + const attemptFilter = selectionType === SelectionType.Filtered || selectionType === SelectionType.AllIfNoFilters; + if (attemptFilter) { + const filters = getFilteredSubscriptions(); + const filteredSubs = subscriptions.filter((s) => filters.some((f: any) => f.subscriptionId === s.subscriptionId)); + const returnAll = selectionType === SelectionType.AllIfNoFilters && filteredSubs.length === 0; + if (!returnAll) { + subscriptions = filteredSubs; + } + } + + return subscriptions.sort((a, b) => a.displayName.localeCompare(b.displayName)); + } + + function isDefinedSubscription(sub: Subscription): sub is DefinedSubscription { + return sub.subscriptionId !== undefined && sub.displayName !== undefined; + } + + export async function setFilteredSubscriptions(filters: SubscriptionFilter[]): Promise { + const existingFilters = getFilteredSubscriptions(); + const filtersChanged = + existingFilters.length !== filters.length || + !filters.every((f) => existingFilters.some((ef) => ef.subscriptionId === f.subscriptionId)); + + const values = filters.map((f) => `${f.tenantId}/${f.subscriptionId}`).sort(); + + if (filtersChanged) { + await vscode.workspace + .getConfiguration(extensionPrefix) + .update(AzureLoginConstantString.selectedSubscriptions, values, vscode.ConfigurationTarget.Global, true); + onFilteredSubscriptionsChangeEmitter.fire(); + } + } + + export function getSubscriptionClient(sessionProvider: ReadyAzureSessionProvider): SubscriptionClient { + return new SubscriptionClient(AzureAuth.getCredential(sessionProvider), { endpoint: getArmEndpoint() }); + } + function getArmEndpoint(): string { + return AzureAuth.getEnvironment().resourceManagerEndpointUrl; + } +} diff --git a/src/commands/openUrl.ts b/src/commands/openUrl.ts new file mode 100644 index 0000000..756db93 --- /dev/null +++ b/src/commands/openUrl.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { AzExtTreeItem, IActionContext, openUrl } from '@microsoft/vscode-azext-utils'; +import { APIMAccountCommandId, AzureAccountUrl } from "../constants"; + +export async function openUrlFromTreeNode(context: IActionContext, node?: AzExtTreeItem) { + context; + switch (node?.id) { + case APIMAccountCommandId.createAzureAccount: { + await openUrl(AzureAccountUrl.createAzureAccountUrl); + break; + } + case APIMAccountCommandId.createAzureStudentAccount: { + await openUrl(AzureAccountUrl.createAzureStudentUrl); + break; + } + default: + break; + } +} diff --git a/src/constants.ts b/src/constants.ts index 483e896..ceba694 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -39,6 +39,23 @@ export enum GatewayKeyType { secondary = "secondary" } +export const APIMAccountCommandId = { + createAzureAccount: "azureApiManagementCreateAzureAccount", + createAzureStudentAccount: "azureApiManagementCreateAzureStudentAccount", + accountLoading: "azureApiManagementAccountLoading", + accountSignIn: "azureApiManagementAccountSignIn", + accountSigningIn: "azureApiManagementAccountSigningIn", + accountSelectTenant: "azureApiManagementAccountSelectTenant", + accountError: "azureApiManagementAccountError", + accountSubscription: "azureApiManagementAccountSubscription", +} + +export const AzureAccountUrl = { + createAzureAccountUrl: "https://aka.ms/VSCodeCreateAzureAccount", + createAzureStudentUrl: "https://aka.ms/student-account", + azureMicrosoftLink: "https://azure.microsoft.com/", +}; + // constants for extractor export const templatesFolder = "templates"; diff --git a/src/debugger/apimDebug.ts b/src/debugger/apimDebug.ts index 1ca0943..bacbcf3 100644 --- a/src/debugger/apimDebug.ts +++ b/src/debugger/apimDebug.ts @@ -2,7 +2,6 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ -import { TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; import * as request from 'request-promise-native'; import * as vscode from 'vscode'; import { Breakpoint, Handles, InitializedEvent, Logger, logger, LoggingDebugSession, OutputEvent, Scope, StackFrame, StoppedEvent, TerminatedEvent, Thread, ThreadEvent, Variable } from 'vscode-debugadapter'; @@ -17,7 +16,9 @@ import { DebuggerConnection, RequestContract } from './debuggerConnection'; import { PolicySource } from './policySource'; import { UiRequest } from './uiRequest'; import { UiThread } from './uiThread'; - +import { AzureAuth } from "../azure/azureLogin/azureAuth"; +import { GeneralUtils } from "../utils/generalUtils"; +import { TokenCredential } from "@azure/core-auth"; // tslint:disable: no-unsafe-any // tslint:disable: indent // tslint:disable: export-name @@ -91,7 +92,7 @@ export class ApimDebugSession extends LoggingDebugSession { masterKey = await this.getMasterSubscriptionKey(args.managementAddress, undefined, args.managementAuth); this.availablePolicies = await this.getAvailablePolicies(args.managementAddress, undefined, args.managementAuth); } else { - const credential = await this.getAccountCredentials(args.subscriptionId); + const credential = await this.getAccountCredentials(); this.policySource = new PolicySource(args.managementAddress, credential); masterKey = await this.getMasterSubscriptionKey(args.managementAddress, credential); this.availablePolicies = await this.getAvailablePolicies(args.managementAddress, credential); @@ -377,18 +378,15 @@ export class ApimDebugSession extends LoggingDebugSession { } } - private async getAccountCredentials(subscriptionId: string): Promise { - const azureAccountExtension = vscode.extensions.getExtension('ms-vscode.azure-account'); - const azureAccount = azureAccountExtension!.exports; - await azureAccount.waitForFilters(); - if (azureAccount.status !== 'LoggedIn') { - throw new Error("ERROR!"); + private async getAccountCredentials(): Promise { + const session = await AzureAuth.getReadySessionProvider(); + if (GeneralUtils.failed(session)) { + throw new Error("Failed to access the Azure Account Session."); } - const creds = azureAccount.filters.filter(filter => filter.subscription.subscriptionId === subscriptionId).map(filter => filter.session.credentials); - return creds[0]; + return await AzureAuth.getCredential(session.result); } - private async getMasterSubscriptionKey(managementAddress: string, credential?: TokenCredentialsBase, managementAuth?: string) { + private async getMasterSubscriptionKey(managementAddress: string, credential?: TokenCredential, managementAuth?: string) { const resourceUrl = `${managementAddress}/subscriptions/master/listSecrets?api-version=${Constants.apimApiVersion}`; const authToken = managementAuth ? managementAuth : await getBearerToken(resourceUrl, "GET", credential!); const subscription: IMasterSubscriptionsSecrets = await request.post(resourceUrl, { @@ -409,7 +407,7 @@ export class ApimDebugSession extends LoggingDebugSession { return subscription.primaryKey; } - private async getAvailablePolicies(managementAddress: string, credential?: TokenCredentialsBase, managementAuth?: string) { + private async getAvailablePolicies(managementAddress: string, credential?: TokenCredential, managementAuth?: string) { const resourceUrl = `${managementAddress}/policyDescriptions?api-version=${Constants.apimApiVersion}`; const authToken = managementAuth ? managementAuth : await getBearerToken(resourceUrl, "GET", credential!); const policyDescriptions: IPaged = await request.get(resourceUrl, { diff --git a/src/debugger/policySource.ts b/src/debugger/policySource.ts index b5f291f..17fd55b 100644 --- a/src/debugger/policySource.ts +++ b/src/debugger/policySource.ts @@ -1,8 +1,7 @@ /*--------------------------------------------------------- * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ - -import { TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; +import { TokenCredential } from "@azure/core-auth"; import * as path from 'path'; import * as request from 'request-promise-native'; import { Source } from 'vscode-debugadapter'; @@ -25,11 +24,11 @@ export class PolicySource { private static NextSourceReference : number = 1; private managementAddress: string; - private credential: TokenCredentialsBase | undefined; + private credential: TokenCredential | undefined; private auth: string | undefined; private policies: { [key: string]: Policy } = {}; - constructor(managementAddress: string, credential?: TokenCredentialsBase, auth?: string) { + constructor(managementAddress: string, credential?: TokenCredential, auth?: string) { this.managementAddress = managementAddress; this.credential = credential; this.auth = auth; diff --git a/src/explorer/AzureAccountTreeItem.ts b/src/explorer/AzureAccountTreeItem.ts index e38da59..ad41d04 100644 --- a/src/explorer/AzureAccountTreeItem.ts +++ b/src/explorer/AzureAccountTreeItem.ts @@ -2,17 +2,196 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { UiStrings } from "../uiStrings"; +import * as vscode from "vscode"; +import { AzExtParentTreeItem, AzExtTreeItem, registerEvent, GenericTreeItem, ISubscriptionContext } from '@microsoft/vscode-azext-utils'; +import { AzureSubscriptionHelper } from "../azure/azureLogin/subscriptions"; +import { AzureSessionProvider, ReadyAzureSessionProvider, SelectionType, SignInStatus } from "../azure/azureLogin/authTypes"; +import { GeneralUtils } from "../utils/generalUtils"; +import { AzureAuth } from "../azure/azureLogin/azureAuth"; +import { APIMAccountCommandId } from "../constants"; +import { Subscription } from "@azure/arm-resources-subscriptions"; +import { ApiManagementProvider } from "./ApiManagementProvider"; -import { ISubscriptionContext } from '@microsoft/vscode-azext-utils'; -import { AzureAccountTreeItemBase } from '@microsoft/vscode-azext-azureutils'; -import { ApiManagementProvider } from './ApiManagementProvider'; - -export class AzureAccountTreeItem extends AzureAccountTreeItemBase { - public constructor(testAccount?: {}) { - super(undefined, testAccount); +export class AzureAccountTreeItem extends AzExtParentTreeItem { + private subscriptionTreeItems: AzExtTreeItem[] | undefined; + public static contextValue: string = "azureApiManagementAzureAccount"; + public readonly contextValue: string = AzureAccountTreeItem.contextValue; + constructor(private readonly sessionProvider: AzureSessionProvider) { + super(undefined); + this.autoSelectInTreeItemPicker = true; + + const onStatusChange = this.sessionProvider.signInStatusChangeEvent; + const onFilteredSubscriptionsChange = AzureSubscriptionHelper.getFilteredSubscriptionsChangeEvent(); + registerEvent("azureAccountTreeItem.onSignInStatusChange", onStatusChange, (context) => this.refresh(context)); + registerEvent("azureAccountTreeItem.onSubscriptionFilterChange", onFilteredSubscriptionsChange, (context) => + this.refresh(context), + ); } - - public createSubscriptionTreeItem(root: ISubscriptionContext): ApiManagementProvider { - return new ApiManagementProvider(this, root); + + public override get label() { + return UiStrings.AzureAccount; + } + + public dispose(): void { } + + public hasMoreChildrenImpl(): boolean { + return false; + } + + // no need to sort the array + public compareChildrenImpl(_item1: AzExtTreeItem, _item2: AzExtTreeItem): number { + return 0; + } + + public async loadMoreChildrenImpl(): Promise { + const existingSubscriptionTreeItems: AzExtTreeItem[] = this.subscriptionTreeItems || []; + this.subscriptionTreeItems = []; + + switch (this.sessionProvider.signInStatus) { + case SignInStatus.Initializing: + return [ + new GenericTreeItem(this, { + label: UiStrings.Loading, + contextValue: "azureCommand", + id: APIMAccountCommandId.accountLoading, + iconPath: new vscode.ThemeIcon("loading~spin"), + }), + ]; + case SignInStatus.SignedOut: + return [ + new GenericTreeItem(this, { + label: UiStrings.SignIntoAzure, + commandId: "azureApiManagement.signInToAzure", + contextValue: "azureCommand", + id: APIMAccountCommandId.accountSignIn, + iconPath: new vscode.ThemeIcon("sign-in"), + includeInTreeItemPicker: true, + }), + new GenericTreeItem(this, { + label: UiStrings.CreateAzureAccount, + commandId: "azureApiManagement.openUrl", + contextValue: "azureCommand", + id: APIMAccountCommandId.createAzureAccount, + iconPath: new vscode.ThemeIcon("add"), + includeInTreeItemPicker: true, + }), + new GenericTreeItem(this, { + label: UiStrings.CreateAzureStudentAccount, + commandId: "azureApiManagement.openUrl", + contextValue: "azureCommand", + id: APIMAccountCommandId.createAzureStudentAccount, + iconPath: new vscode.ThemeIcon("mortar-board"), + includeInTreeItemPicker: true, + }) + ]; + case SignInStatus.SigningIn: + return [ + new GenericTreeItem(this, { + label: UiStrings.WaitForAzureSignIn, + contextValue: "azureCommand", + id: APIMAccountCommandId.accountSigningIn, + iconPath: new vscode.ThemeIcon("loading~spin"), + }), + ]; + } + + if (this.sessionProvider.selectedTenant === null && this.sessionProvider.availableTenants.length > 1) { + // Signed in, but no tenant selected, AND there is more than one tenant to choose from. + return [ + new GenericTreeItem(this, { + label: UiStrings.SelectTenant, + commandId: "azureApiManagement.selectTenant", + contextValue: "azureCommand", + id: APIMAccountCommandId.accountSelectTenant, + iconPath: new vscode.ThemeIcon("account"), + includeInTreeItemPicker: true, + }), + ]; + } + + // Either we have a selected tenant, or there is only one available tenant and it's not selected + // because it requires extra interaction. Calling `getAuthSession` will complete that process. + // We will need the returned auth session in any case for creating a subscription context. + const session = await this.sessionProvider.getAuthSession(); + if (GeneralUtils.failed(session) || !AzureAuth.isReady(this.sessionProvider)) { + return [ + new GenericTreeItem(this, { + label: UiStrings.ErrorAuthenticating, + contextValue: "azureCommand", + id: APIMAccountCommandId.accountError, + iconPath: new vscode.ThemeIcon("error"), + }), + ]; + } + + const subscriptions = await AzureSubscriptionHelper.getSubscriptions(this.sessionProvider, SelectionType.Filtered); + if (GeneralUtils.failed(subscriptions)) { + return [ + new GenericTreeItem(this, { + label: UiStrings.ErrorLoadingSubscriptions, + contextValue: "azureCommand", + id: APIMAccountCommandId.accountError, + iconPath: new vscode.ThemeIcon("error"), + description: subscriptions.error, + }), + ]; + } + + if (subscriptions.result.length === 0) { + return [ + new GenericTreeItem(this, { + label: UiStrings.SelectSubscriptionInPanel, + commandId: "azureApiManagement.selectSubscriptions", + contextValue: "azureCommand", + id: APIMAccountCommandId.accountSubscription, + includeInTreeItemPicker: true, + }), + ]; + } + + // We've confirmed above that the provider is ready. + const readySessionProvider: ReadyAzureSessionProvider = this.sessionProvider; + + this.subscriptionTreeItems = await Promise.all( + subscriptions.result.map(async (subscription: any) => { + const existingTreeItem: AzExtTreeItem | undefined = existingSubscriptionTreeItems.find( + (ti) => ti.id === subscription.subscriptionId, + ); + if (existingTreeItem) { + return existingTreeItem; + } else { + const subscriptionContext = getSubscriptionContext( + readySessionProvider, + session.result, + subscription, + ); + return new ApiManagementProvider(this, subscriptionContext); + } + }), + ); + + return this.subscriptionTreeItems!; } -} + } + + function getSubscriptionContext( + sessionProvider: ReadyAzureSessionProvider, + session: vscode.AuthenticationSession, + subscription: Subscription, + ): ISubscriptionContext { + const credentials = AzureAuth.getCredential(sessionProvider); + const environment = AzureAuth.getEnvironment(); + + return { + credentials, + createCredentialsForScopes: ()=> { return Promise.resolve(credentials); }, + subscriptionDisplayName: subscription.displayName || "", + subscriptionId: subscription.subscriptionId || "", + subscriptionPath: `/subscriptions/${subscription.subscriptionId}`, + tenantId: subscription.tenantId || "", + userId: session.account.id, + environment, + isCustomCloud: environment.name === "AzureCustomCloud", + }; + } \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 27e493b..3e10e6d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -79,7 +79,9 @@ import { SubscriptionTreeItem } from './explorer/SubscriptionTreeItem'; import { ext } from './extensionVariables'; import { localize } from './localize'; import { registerAzureUtilsExtensionVariables } from '@microsoft/vscode-azext-azureutils'; - +import { AzureSessionProviderHelper } from "./azure/azureLogin/azureSessionProvider"; +import { AzureAccount } from "./azure/azureLogin/azureAccount"; +import { openUrlFromTreeNode } from './commands/openUrl'; // this method is called when your extension is activated // your extension is activated the very first time the command is executed // tslint:disable-next-line:typedef @@ -96,10 +98,13 @@ export async function activateInternal(context: vscode.ExtensionContext) { await callWithTelemetryAndErrorHandling('azureApiManagement.Activate', async (activateContext: IActionContext) => { activateContext.telemetry.properties.isActivationEvent = 'true'; - ext.azureAccountTreeItem = new AzureAccountTreeItem(); - context.subscriptions.push(ext.azureAccountTreeItem); - - ext.tree = new AzExtTreeDataProvider(ext.azureAccountTreeItem, 'azureApiManagement.LoadMore'); + AzureSessionProviderHelper.activateAzureSessionProvider(context); + const sessionProvider = AzureSessionProviderHelper.getSessionProvider(); + const azureAccountTreeItem = new AzureAccountTreeItem(sessionProvider); + context.subscriptions.push(azureAccountTreeItem); + ext.azureAccountTreeItem = azureAccountTreeItem; + + ext.tree = new AzExtTreeDataProvider(azureAccountTreeItem, 'azureApiManagement.LoadMore'); context.subscriptions.push(vscode.window.registerTreeDataProvider('azureApiManagementExplorer', ext.tree)); registerCommands(ext.tree); @@ -115,8 +120,11 @@ export async function activateInternal(context: vscode.ExtensionContext) { } function registerCommands(tree: AzExtTreeDataProvider): void { - registerCommand('azureApiManagement.Refresh', async (context: IActionContext, node?: AzExtTreeItem) => await tree.refresh(context, node)); // need to double check - registerCommand('azureApiManagement.selectSubscriptions', () => vscode.commands.executeCommand("azure-account.selectSubscriptions")); + registerCommand('azureApiManagement.signInToAzure', async () => { await AzureAccount.signInToAzure(); }); + registerCommand('azureApiManagement.openUrl', async(context: IActionContext, node?: AzExtTreeItem) => { await openUrlFromTreeNode(context, node); }); + registerCommand('azureApiManagement.Refresh', async (context: IActionContext) => await ext.azureAccountTreeItem.refresh(context)); // need to double check + registerCommand('azureApiManagement.selectSubscriptions', async() => { await AzureAccount.selectSubscriptions();}); + registerCommand('azureApiManagement.selectTenant', async() => { await AzureAccount.selectTenant();}); registerCommand('azureApiManagement.LoadMore', async (context: IActionContext, node: AzExtTreeItem) => await tree.loadMore(node, context)); // need to double check registerCommand('azureApiManagement.openInPortal', openInPortal); registerCommand('azureApiManagement.createService', createService); diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts index aa5a698..db7c7a3 100644 --- a/src/extensionVariables.ts +++ b/src/extensionVariables.ts @@ -2,9 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ExtensionContext } from "vscode"; -import { AzExtTreeDataProvider, IAzExtOutputChannel } from "@microsoft/vscode-azext-utils"; -import { AzureAccountTreeItem } from "./explorer/AzureAccountTreeItem"; +import { ExtensionContext, Disposable } from "vscode"; +import { AzExtTreeDataProvider, IAzExtOutputChannel, AzExtParentTreeItem } from "@microsoft/vscode-azext-utils"; /** * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts @@ -13,7 +12,7 @@ export namespace ext { export let context: ExtensionContext; export let tree: AzExtTreeDataProvider; export let outputChannel: IAzExtOutputChannel; - export let azureAccountTreeItem: AzureAccountTreeItem; + export let azureAccountTreeItem: AzExtParentTreeItem & Disposable; export const prefix: string = 'azureAPIM'; //export let reporter: ITelemetryContext; } diff --git a/src/uiStrings.ts b/src/uiStrings.ts new file mode 100644 index 0000000..ebfed44 --- /dev/null +++ b/src/uiStrings.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import * as vscode from "vscode"; + +export class UiStrings { + static readonly AzureAccount = vscode.l10n.t("Azure"); + static readonly Loading = vscode.l10n.t("Loading..."); + static readonly CreateAzureStudentAccount = vscode.l10n.t("Create an Azure for Students Account..."); + static readonly CreateAzureAccount = vscode.l10n.t("Create an Azure Account..."); + static readonly WaitForAzureSignIn = vscode.l10n.t("Waiting for Azure sign-in..."); + static readonly SignIntoAzure = vscode.l10n.t("Sign in to Azure..."); + static readonly SelectTenant = vscode.l10n.t("Select tenant..."); + static readonly ErrorAuthenticating = vscode.l10n.t("Error authenticating"); + static readonly SelectTenantBeforeSignIn = vscode.l10n.t("You must sign in before selecting a tenant."); + static readonly NoTenantSelected = vscode.l10n.t("No tenant selected."); + static readonly NoSubscriptionsFoundAndSetup = vscode.l10n.t("No subscriptions were found. Set up your account if you have yet to do so."); + static readonly SetUpAzureAccount = vscode.l10n.t("Set up Account"); + static readonly NoSubscriptionsFound = vscode.l10n.t("No subscriptions found"); + static readonly ErrorLoadingSubscriptions = vscode.l10n.t("Error loading subscriptions"); + static readonly SelectSubscription = vscode.l10n.t("Select Subscriptions"); + static readonly NoMSAuthSessionFound = vscode.l10n.t("No Microsoft authentication session found: {0}"); + static readonly SelectATenant = vscode.l10n.t("Select a tenant"); + static readonly WaitForSignIn = vscode.l10n.t("Waiting for sign-in"); + static readonly CustomCloudChoiseNotConfigured = vscode.l10n.t("The custom cloud choice is not configured. Please configure the setting {0}.{1}."); + static readonly FailedToListGroup = vscode.l10n.t("Failed to list resources: {0}"); + static readonly NotSignInStatus = vscode.l10n.t("Not signed in {0}"); + static readonly NoTenantFound = vscode.l10n.t("No tenants found."); + static readonly NoAzureSessionFound = vscode.l10n.t("No Azure session found."); + static readonly FailedTo = vscode.l10n.t("Failed to retrieve Azure session: {0}"); + static readonly SelectSubscriptionInPanel = vscode.l10n.t("Select Subscriptions..."); +} diff --git a/src/utils/azureClientUtil.ts b/src/utils/azureClientUtil.ts index 8907751..0b7b231 100644 --- a/src/utils/azureClientUtil.ts +++ b/src/utils/azureClientUtil.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { WebSiteManagementClient } from "@azure/arm-appservice"; -import * as vscode from 'vscode'; import { createAzureClient } from '@microsoft/vscode-azext-azureutils'; -import { localize } from "../localize"; import { AzExtTreeItem, IActionContext } from "@microsoft/vscode-azext-utils"; +import { AzureAccount } from "../azure/azureLogin/azureAccount"; +import { AzureSubscriptionHelper } from "../azure/azureLogin/subscriptions"; export namespace azureClientUtil { export function getClient(context: IActionContext, node: AzExtTreeItem): WebSiteManagementClient { @@ -14,19 +14,9 @@ export namespace azureClientUtil { } // tslint:disable: no-unsafe-any - export async function selectSubscription(context: IActionContext): Promise { - const azureAccountExtension = vscode.extensions.getExtension('ms-vscode.azure-account'); - // tslint:disable-next-line: no-non-null-assertion - const azureAccount = azureAccountExtension!.exports; - await azureAccount.waitForFilters(); - if (azureAccount.status !== 'LoggedIn') { - throw new Error(localize("", "Please Log in at first!")); - } - const subscriptions : {id: string, name: string}[] = azureAccount.filters.map(filter => {return {id: filter.subscription.subscriptionId, name: filter.subscription.displayName}; }); - const subscriptionId = await context.ui.showQuickPick(subscriptions.map((s) => { - const option = s.id.concat(' (', s.name, ')'); - return { label: option, subscriptionId: s.id}; - }), { canPickMany: false, placeHolder: localize("", "Please choose the Azure subscription")}); - return subscriptionId.subscriptionId; + export async function selectSubscription(_context: IActionContext): Promise { + await AzureAccount.selectSubscriptions(); + let res = await AzureSubscriptionHelper.getFilteredSubscriptions(); + return res[0].subscriptionId; } } diff --git a/src/utils/generalUtils.ts b/src/utils/generalUtils.ts new file mode 100644 index 0000000..127f3d5 --- /dev/null +++ b/src/utils/generalUtils.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export namespace GeneralUtils { + export async function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + export interface Succeeded { + readonly succeeded: true; + readonly result: T; + } + + export interface Failed { + readonly succeeded: false; + readonly error: string; + } + + export type Errorable = Succeeded | Failed; + + export function succeeded(e: Errorable): e is Succeeded { + return e.succeeded; + } + + export function failed(e: Errorable): e is Failed { + return !e.succeeded; + } + + export function errMap(e: Errorable, fn: (t: T) => U): Errorable { + if (failed(e)) { + return { succeeded: false, error: e.error }; + } + return { succeeded: true, result: fn(e.result) }; + } + + export function getErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message; + } + return String(error); + } + + export function bindAsync(e: Errorable, fn: (t: T) => Promise>): Promise> { + if (failed(e)) { + return Promise.resolve(e); + } + return fn(e.result); + } +} diff --git a/src/utils/requestUtil.ts b/src/utils/requestUtil.ts index 8137cc6..9bdc959 100644 --- a/src/utils/requestUtil.ts +++ b/src/utils/requestUtil.ts @@ -3,13 +3,14 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { HttpMethods, HttpOperationResponse, ParameterValue, ServiceClient, WebResource } from "@azure/ms-rest-js"; -import { TokenCredentialsBase } from "@azure/ms-rest-nodeauth"; +import { HttpMethods, HttpOperationResponse, ParameterValue, ServiceClient, WebResource, Constants as MSRestConstants } from "@azure/ms-rest-js"; +import { AccessToken, TokenCredential } from "@azure/core-auth"; import requestPromise from 'request-promise'; import { appendExtensionUserAgent } from '@microsoft/vscode-azext-utils'; import { AzExtServiceClientCredentials } from "@microsoft/vscode-azext-utils"; import { clientOptions } from "../azure/clientOptions"; - +import { Environment } from "@azure/ms-rest-azure-env"; +import { AzureAuth } from "../azure/azureLogin/azureAuth"; export type nRequest = WebResource & requestPromise.RequestPromiseOptions; // tslint:disable-next-line: no-any @@ -28,13 +29,13 @@ export async function sendRequest(httpReq: nRequest): Promise { } // tslint:disable: no-unsafe-any -export async function getBearerToken(url: string, method: HttpMethods, credentials: TokenCredentialsBase): Promise { +export async function getBearerToken(url: string, method: HttpMethods, credentials: TokenCredential): Promise { const requestOptions: WebResource = new WebResource(); requestOptions.headers.set("User-Agent", appendExtensionUserAgent()); requestOptions.url = url; requestOptions.method = method; try { - await credentials.signRequest(requestOptions); + await signRequest(credentials, requestOptions); } catch (err) { throw err; } @@ -47,3 +48,24 @@ export async function getBearerToken(url: string, method: HttpMethods, credentia return authToken; } } + +export function getDefaultMsalScopes(environment: Environment): string[] { + return [ + createMsalScope(environment.managementEndpointUrl) + ]; +} + +function createMsalScope(authority: string, scope: string = '.default'): string { + return authority.endsWith('/') ? + `${authority}${scope}` : + `${authority}/${scope}`; +} + +export async function signRequest(credential: TokenCredential, webResource: WebResource): Promise { + const tokenResponse: AccessToken| null = await credential.getToken(getDefaultMsalScopes(AzureAuth.getEnvironment())); + if(tokenResponse) { + webResource.headers[MSRestConstants.HeaderConstants.AUTHORIZATION]= `${MSRestConstants.HeaderConstants.AUTHORIZATION_SCHEME} ${tokenResponse.token}`; + return webResource; + } + return undefined; +} diff --git a/test/createService.test.ts b/test/createService.test.ts index 777ad48..a6707c7 100644 --- a/test/createService.test.ts +++ b/test/createService.test.ts @@ -10,9 +10,9 @@ import { ResourceManagementClient } from '@azure/arm-resources'; import { Context, Suite } from 'mocha'; import * as vscode from 'vscode'; import { ISubscriptionContext, TestAzureAccount} from '@microsoft/vscode-azext-dev'; -import { AzExtTreeDataProvider, AzExtParentTreeItem } from '@microsoft/vscode-azext-utils'; +import { AzExtParentTreeItem } from '@microsoft/vscode-azext-utils'; //import { ApiManagementProvider, AzureTreeDataProvider, ext, getRandomHexString, TestAzureAccount, TestUserInput, treeUtils } from '../extension.bundle'; -import { AzureAccountTreeItem, ext, treeUtils } from '../extension.bundle'; +import { ext, treeUtils } from '../extension.bundle'; import { longRunningTestsEnabled } from './global.test'; import { HttpHeaders } from '@azure/ms-rest-js'; //import { runWithApimSetting } from './runWithSetting'; @@ -32,8 +32,6 @@ suite('Create Azure Resources', async function (this: Suite): Promise { this.timeout(120 * 1000); await testAccount.signIn(); - ext.azureAccountTreeItem = new AzureAccountTreeItem(testAccount); - ext.tree = new AzExtTreeDataProvider(ext.azureAccountTreeItem, 'azureApiManagement.LoadMore'); const rootNode : AzExtParentTreeItem = await treeUtils.getRootNode(ext.tree); rootNode.subscription.userId = "vscodeapimtest@microsoft.com"; // userId doesnt exist for service principal. //apiManagementClient = getApiManagementClient(testAccount);