diff --git a/auth/src/AzureSessionProvider.ts b/auth/src/AzureSessionProvider.ts new file mode 100644 index 0000000000..5320c1e262 --- /dev/null +++ b/auth/src/AzureSessionProvider.ts @@ -0,0 +1,25 @@ +import { type TenantIdDescription } from "@azure/arm-resources-subscriptions"; +import { type AuthenticationSession, type Event } from "vscode"; + +export type SignInStatus = "Initializing" | "SigningIn" | "SignedIn" | "SignedOut"; + +export type AzureAuthenticationSession = AuthenticationSession & { + tenantId: string; +}; + +export type DefinedTenant = TenantIdDescription & Required>; + +export enum GetSessionBehavior { + Silent, + PromptIfRequired, +} + +export type AzureSessionProvider = { + signIn(): Promise; + signInStatus: SignInStatus; + tenants: DefinedTenant[]; + isSignedInToTenant(tenantId: string): boolean; + signInStatusChangeEvent: Event; + getAuthSession(tenantId: string, behavior: GetSessionBehavior, scopes?: string[]): Promise; + dispose(): void; +}; diff --git a/auth/src/VSCodeAzureSessionProvider.ts b/auth/src/VSCodeAzureSessionProvider.ts new file mode 100644 index 0000000000..d299dcdd95 --- /dev/null +++ b/auth/src/VSCodeAzureSessionProvider.ts @@ -0,0 +1,241 @@ +import { type TenantIdDescription } from "@azure/arm-resources-subscriptions"; +import { AuthenticationGetSessionOptions, AuthenticationSession, Event, EventEmitter, Disposable as VSCodeDisposable, authentication } from "vscode"; +import { AzureAuthenticationSession, AzureSessionProvider, DefinedTenant, GetSessionBehavior, SignInStatus } from "./AzureSessionProvider"; +import { NotSignedInError } from "./NotSignedInError"; +import { getConfiguredAuthProviderId, getConfiguredAzureEnv } from "./utils/configuredAzureEnv"; +import { getSubscriptionClient } from "./utils/resourceManagement"; + +enum AuthScenario { + Initialization, + SignIn, + GetSessionSilent, + GetSessionPrompt, +} + +type TenantSignInStatus = { + tenant: DefinedTenant; + isSignedIn: boolean; +}; + +export class VSCodeAzureSessionProvider extends VSCodeDisposable implements AzureSessionProvider { + private readonly initializePromise: Promise; + private handleSessionChanges: boolean = true; + private tenantSignInStatuses: TenantSignInStatus[] = []; + + public readonly onSignInStatusChangeEmitter = new EventEmitter(); + public signInStatusValue: SignInStatus = "Initializing"; + + public constructor() { + const disposable = authentication.onDidChangeSessions(async (e) => { + // Ignore events for non-microsoft providers + if (e.provider.id !== 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 tenants(): DefinedTenant[] { + return this.tenantSignInStatuses.map(s => s.tenant); + } + + public isSignedInToTenant(tenantId: string): boolean { + return this.tenantSignInStatuses.some(s => s.tenant.tenantId === tenantId && s.isSignedIn); + } + + 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 = "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 scopes = [getDefaultScope(getConfiguredAzureEnv().resourceManagerEndpointUrl)]; + const getSessionResult = await this.getArmSession("organizations", authScenario, scopes); + if (getSessionResult === undefined) { + if (this.tenantSignInStatuses.length > 0 || this.signInStatusValue !== "SignedOut") { + this.tenantSignInStatuses = []; + this.signInStatusValue = "SignedOut"; + this.onSignInStatusChangeEmitter.fire(this.signInStatusValue); + } + + return; + } + + // Get the tenants + const allTenants = await getTenants(getSessionResult); + + const signInStatusesPromises = allTenants.map>(async (t) => { + const session = await this.getArmSession(t.tenantId, AuthScenario.Initialization, scopes); + return { + tenant: t, + isSignedIn: session !== undefined, + }; + }); + + const newTenantSignInStatuses = await Promise.all(signInStatusesPromises); + const tenantsChanged = !areStringCollectionsEqual( + this.tenantSignInStatuses.map(s => s.tenant.tenantId), + newTenantSignInStatuses.map(s => s.tenant.tenantId)); + + // Get the overall sign-in status. If the user has access to any tenants they are signed in. + const newSignInStatus = newTenantSignInStatuses.length > 0 ? "SignedIn" : "SignedOut"; + const signInStatusChanged = newSignInStatus !== this.signInStatusValue; + + // Update the state and fire event if anything has changed. + this.tenantSignInStatuses = newTenantSignInStatuses; + this.signInStatusValue = newSignInStatus; + if (signInStatusChanged || tenantsChanged) { + 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 is thrown. + */ + public async getAuthSession(tenantId: string, behavior: GetSessionBehavior, scopes?: string[]): Promise { + await this.initializePromise; + if (this.signInStatusValue !== "SignedIn") { + throw new NotSignedInError(); + } + + const tenantSignInStatus = this.tenantSignInStatuses.find(s => s.tenant.tenantId === tenantId); + if (!tenantSignInStatus) { + throw new Error(`User does not have access to tenant ${tenantId}`); + } + + // Get a session for a specific tenant. + scopes = scopes || [getDefaultScope(getConfiguredAzureEnv().resourceManagerEndpointUrl)]; + const behaviourScenarios: Record = { + [GetSessionBehavior.Silent]: AuthScenario.GetSessionSilent, + [GetSessionBehavior.PromptIfRequired]: AuthScenario.GetSessionPrompt, + }; + + const session = await this.getArmSession(tenantId, behaviourScenarios[behavior], scopes); + tenantSignInStatus.isSignedIn = session !== undefined; + + return session; + } + + private async getArmSession( + tenantId: string, + authScenario: AuthScenario, + scopes: string[], + ): Promise { + this.handleSessionChanges = false; + try { + scopes = addTenantIdScope(scopes, tenantId); + + let options: AuthenticationGetSessionOptions; + let silentFirst = false; + switch (authScenario) { + case AuthScenario.Initialization: + case AuthScenario.GetSessionSilent: + options = { createIfNone: false, clearSessionPreference: false, silent: true }; + break; + case AuthScenario.SignIn: + options = { createIfNone: true, clearSessionPreference: true, silent: false }; + break; + case AuthScenario.GetSessionPrompt: + // 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(getConfiguredAuthProviderId(), scopes, { silent: true }); + } + + if (!session) { + session = await authentication.getSession(getConfiguredAuthProviderId(), scopes, options); + } + + if (!session) { + return undefined; + } + + return Object.assign(session, { tenantId }); + } finally { + this.handleSessionChanges = true; + } + } +} + +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`; +} + +async function getTenants(session: AuthenticationSession): Promise { + const { client } = await getSubscriptionClient(session); + + const results: TenantIdDescription[] = []; + for await (const tenant of client.tenants.list()) { + results.push(tenant); + } + + return results.filter(isDefinedTenant); +} + +function isDefinedTenant(tenant: TenantIdDescription): tenant is DefinedTenant { + return tenant.tenantId !== undefined && tenant.displayName !== undefined; +} + +function areStringCollectionsEqual(values1: string[], values2: string[]): boolean { + return values1.sort().join(",") === values2.sort().join(","); +} + +function addTenantIdScope(scopes: string[], tenantId: string): string[] { + const scopeSet = new Set(scopes); + scopeSet.add(`VSCODE_TENANT:${tenantId}`); + return Array.from(scopeSet); +} diff --git a/auth/src/VSCodeAzureSubscriptionProvider.ts b/auth/src/VSCodeAzureSubscriptionProvider.ts index 8ccb775a56..f2ac3ecd26 100644 --- a/auth/src/VSCodeAzureSubscriptionProvider.ts +++ b/auth/src/VSCodeAzureSubscriptionProvider.ts @@ -3,15 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { SubscriptionClient, TenantIdDescription } from '@azure/arm-resources-subscriptions'; // Keep this as `import type` to avoid actually loading the package before necessary -import type { TokenCredential } from '@azure/core-auth'; // Keep this as `import type` to avoid actually loading the package (at all, this one is dev-only) +import type { TenantIdDescription } from '@azure/arm-resources-subscriptions'; // Keep this as `import type` to avoid actually loading the package before necessary import * as vscode from 'vscode'; -import type { AzureAuthentication } from './AzureAuthentication'; +import { AzureSessionProvider, GetSessionBehavior } from './AzureSessionProvider'; import type { AzureSubscription, SubscriptionId, TenantId } from './AzureSubscription'; import type { AzureSubscriptionProvider } from './AzureSubscriptionProvider'; import { NotSignedInError } from './NotSignedInError'; -import { getSessionFromVSCode } from './getSessionFromVSCode'; -import { getConfiguredAuthProviderId, getConfiguredAzureEnv } from './utils/configuredAzureEnv'; +import { VSCodeAzureSessionProvider } from './VSCodeAzureSessionProvider'; +import { getConfiguredAzureEnv } from './utils/configuredAzureEnv'; +import { getSubscriptionClient } from './utils/resourceManagement'; const EventDebounce = 5 * 1000; // 5 seconds @@ -27,19 +27,17 @@ export class VSCodeAzureSubscriptionProvider extends vscode.Disposable implement private readonly onDidSignOutEmitter = new vscode.EventEmitter(); private lastSignOutEventFired: number = 0; - public constructor() { - const disposable = vscode.authentication.onDidChangeSessions(async e => { - // Ignore any sign in that isn't for the configured auth provider - if (e.provider.id !== getConfiguredAuthProviderId()) { - return; - } + private readonly sessionProvider: AzureSessionProvider; - if (await this.isSignedIn()) { + public constructor() { + const sessionProvider = new VSCodeAzureSessionProvider(); + const disposable = sessionProvider.signInStatusChangeEvent((status) => { + if (status === "SignedIn") { if (!this.suppressSignInEvents && Date.now() > this.lastSignInEventFired + EventDebounce) { this.lastSignInEventFired = Date.now(); this.onDidSignInEmitter.fire(); } - } else if (Date.now() > this.lastSignOutEventFired + EventDebounce) { + } else if (status === "SignedOut" && Date.now() > this.lastSignOutEventFired + EventDebounce) { this.lastSignOutEventFired = Date.now(); this.onDidSignOutEmitter.fire(); } @@ -50,6 +48,8 @@ export class VSCodeAzureSubscriptionProvider extends vscode.Disposable implement this.onDidSignOutEmitter.dispose(); disposable.dispose(); }); + + this.sessionProvider = sessionProvider; } /** @@ -59,15 +59,7 @@ export class VSCodeAzureSubscriptionProvider extends vscode.Disposable implement * @returns A list of tenants. */ public async getTenants(): Promise { - const { client } = await this.getSubscriptionClient(); - - const results: TenantIdDescription[] = []; - - for await (const tenant of client.tenants.list()) { - results.push(tenant); - } - - return results; + return this.sessionProvider.tenants; } /** @@ -134,8 +126,11 @@ export class VSCodeAzureSubscriptionProvider extends vscode.Disposable implement * @returns True if the user is signed in, false otherwise. */ public async isSignedIn(tenantId?: string): Promise { - const session = await getSessionFromVSCode([], tenantId, { createIfNone: false, silent: true }); - return !!session; + if (this.sessionProvider.signInStatus !== "SignedIn") { + return false; + } + + return tenantId ? this.sessionProvider.isSignedInToTenant(tenantId) : true; } /** @@ -146,8 +141,13 @@ export class VSCodeAzureSubscriptionProvider extends vscode.Disposable implement * @returns True if the user is signed in, false otherwise. */ public async signIn(tenantId?: string): Promise { - const session = await getSessionFromVSCode([], tenantId, { createIfNone: true, clearSessionPreference: true }); - return !!session; + await this.sessionProvider.signIn(); + if (!tenantId) { + return this.sessionProvider.signInStatus === "SignedIn"; + } + + await this.sessionProvider.getAuthSession(tenantId, GetSessionBehavior.PromptIfRequired); + return this.sessionProvider.isSignedInToTenant(tenantId); } /** @@ -208,7 +208,12 @@ export class VSCodeAzureSubscriptionProvider extends vscode.Disposable implement * @returns The list of subscriptions for the tenant. */ private async getSubscriptionsForTenant(tenantId: string): Promise { - const { client, credential, authentication } = await this.getSubscriptionClient(tenantId); + const session = await this.sessionProvider.getAuthSession(tenantId, GetSessionBehavior.Silent); + if (!session) { + throw new NotSignedInError(); + } + + const { client, credential, authentication } = await getSubscriptionClient(session); const environment = getConfiguredAzureEnv(); const subscriptions: AzureSubscription[] = []; @@ -229,39 +234,4 @@ export class VSCodeAzureSubscriptionProvider extends vscode.Disposable implement return subscriptions; } - - /** - * Gets a fully-configured subscription client for a given tenant ID - * - * @param tenantId (Optional) The tenant ID to get a client for - * - * @returns A client, the credential used by the client, and the authentication function - */ - private async getSubscriptionClient(tenantId?: string, scopes?: string[]): Promise<{ client: SubscriptionClient, credential: TokenCredential, authentication: AzureAuthentication }> { - const armSubs = await import('@azure/arm-resources-subscriptions'); - const session = await getSessionFromVSCode(scopes, tenantId, { createIfNone: false, silent: true }); - if (!session) { - throw new NotSignedInError(); - } - - const credential: TokenCredential = { - getToken: async () => { - return { - token: session.accessToken, - expiresOnTimestamp: 0 - }; - } - } - - const configuredAzureEnv = getConfiguredAzureEnv(); - const endpoint = configuredAzureEnv.resourceManagerEndpointUrl; - - return { - client: new armSubs.SubscriptionClient(credential, { endpoint }), - credential: credential, - authentication: { - getSession: () => session - } - }; - } } diff --git a/auth/src/getSessionFromVSCode.ts b/auth/src/getSessionFromVSCode.ts deleted file mode 100644 index 1a24ba8649..0000000000 --- a/auth/src/getSessionFromVSCode.ts +++ /dev/null @@ -1,54 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Microsoft Corporation. All rights reserved. -* Licensed under the MIT License. See License.txt in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import { getConfiguredAuthProviderId, getConfiguredAzureEnv } from "./utils/configuredAzureEnv"; -import * as vscode from "vscode"; - -function ensureEndingSlash(value: string): string { - return value.endsWith('/') ? value : `${value}/`; -} - -function getResourceScopes(scopes?: string | string[]): string[] { - if (scopes === undefined || scopes === "" || scopes.length === 0) { - scopes = ensureEndingSlash(getConfiguredAzureEnv().managementEndpointUrl); - } - const arrScopes = (Array.isArray(scopes) ? scopes : [scopes]) - .map((scope) => { - if (scope.endsWith('.default')) { - return scope; - } else { - return `${scope}.default`; - } - }); - return Array.from(new Set(arrScopes)); -} - -function addTenantIdScope(scopes: string[], tenantId: string): string[] { - const scopeSet = new Set(scopes); - scopeSet.add(`VSCODE_TENANT:${tenantId}`); - return Array.from(scopeSet); -} - -function getScopes(scopes: string | string[] | undefined, tenantId?: string): string[] { - let scopeArr = getResourceScopes(scopes); - if (tenantId) { - scopeArr = addTenantIdScope(scopeArr, tenantId); - } - return scopeArr; -} - -/** - * Wraps {@link vscode.authentication.getSession} and handles: - * * Passing the configured auth provider id - * * Getting the list of scopes, adding the tenant id to the scope list if needed - * - * @param scopes - top-level resource scopes (e.g. http://management.azure.com, http://storage.azure.com) or .default scopes. All resources/scopes will be normalized to the `.default` scope for each resource. - * @param tenantId - (Optional) The tenant ID, will be added to the scopes - * @param options - see {@link vscode.AuthenticationGetSessionOptions} - * @returns An authentication session if available, or undefined if there are no sessions - */ -export async function getSessionFromVSCode(scopes?: string | string[], tenantId?: string, options?: vscode.AuthenticationGetSessionOptions): Promise { - return await vscode.authentication.getSession(getConfiguredAuthProviderId(), getScopes(scopes, tenantId), options); -} diff --git a/auth/src/index.ts b/auth/src/index.ts index aa8c3a4a6f..fabaafc804 100644 --- a/auth/src/index.ts +++ b/auth/src/index.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ export * from './AzureAuthentication'; +export * from './AzureSessionProvider'; export * from './AzureSubscription'; export * from './AzureSubscriptionProvider'; export * from './NotSignedInError'; +export * from './VSCodeAzureSessionProvider'; +export * from './VSCodeAzureSubscriptionProvider'; +export * from './signInToTenant'; export * from './utils/configuredAzureEnv'; export * from './utils/getUnauthenticatedTenants'; -export * from './VSCodeAzureSubscriptionProvider'; -export * from './signInToTenant' + diff --git a/auth/src/utils/resourceManagement.ts b/auth/src/utils/resourceManagement.ts new file mode 100644 index 0000000000..271e701c10 --- /dev/null +++ b/auth/src/utils/resourceManagement.ts @@ -0,0 +1,36 @@ +import { type SubscriptionClient } from '@azure/arm-resources-subscriptions'; +import { type TokenCredential } from '@azure/core-auth'; +import { type AuthenticationSession } from 'vscode'; +import { type AzureAuthentication } from '../AzureAuthentication'; +import { getConfiguredAzureEnv } from './configuredAzureEnv'; + +/** + * Gets a fully-configured subscription client for a given tenant ID + * + * @param tenantId (Optional) The tenant ID to get a client for + * + * @returns A client, the credential used by the client, and the authentication function + */ +export async function getSubscriptionClient(session: AuthenticationSession): Promise<{ client: SubscriptionClient, credential: TokenCredential, authentication: AzureAuthentication }> { + const armSubs = await import('@azure/arm-resources-subscriptions'); + + const credential: TokenCredential = { + getToken: async () => { + return { + token: session.accessToken, + expiresOnTimestamp: 0 + }; + } + } + + const configuredAzureEnv = getConfiguredAzureEnv(); + const endpoint = configuredAzureEnv.resourceManagerEndpointUrl; + + return { + client: new armSubs.SubscriptionClient(credential, { endpoint }), + credential: credential, + authentication: { + getSession: () => session + } + }; +}