Framework/Helpers/AccountHelper.ps1

using namespace Newtonsoft.Json
using namespace Microsoft.Azure.Commands.Common.Authentication.Abstractions
using namespace Microsoft.Azure.Commands.Common.Authentication
using namespace Microsoft.Azure.Management.Storage.Models
using namespace Microsoft.IdentityModel.Clients.ActiveDirectory

Set-StrictMode -Version Latest



# Represents subset of directory roles that we check against for 'AAD admin-or-not'
[Flags()]
enum PrivilegedAADRoles
{
    None = 0
    SecurityReader = 1
    UserAccountAdmin = 2
    SecurityAdmin = 4
    CompanyAdmin = 8
}

#Creates an object for our (internal) representation of a privileged role
#The term 'privileged' or 'privRole' here refers to directory roles we consider in 'admin-or-not' check
#It does not refer to AAD-PIM (at least as yet)
function New-PrivRole()
{
  param ($DisplayName, $ObjectId, $AADPrivRole)

  $privRole = new-object PSObject

  $privRole | add-member -type NoteProperty -Name DisplayName -Value $DisplayName
  $privRole | add-member -type NoteProperty -Name ObjectId -Value $ObjectId
  $privRole | add-member -type NoteProperty -Name AADPrivRole -Value $AADPrivRole

  return $privRole
}

class AccountHelper {
    static hidden [PSObject] $currentAzContext;
    static hidden [PSObject] $currentMgContext;
    static hidden [PSObject] $AADAPIAccessToken;
    static hidden [PSObject] $GraphAccessToken;

    #TODO: 'static' => most of these will get set for session! (Also statics in [Tenant] class)
    #TODO: May need to consider situations where user runs for 2 diff tenants in same session...
    static hidden [string] $tenantInfoMsg; 

    static hidden [PSObject] $currentMgUserObject;

    static hidden [CommandType] $ScanType;

    static hidden [PrivilegedAADRoles] $UserAADPrivRoles = [PrivilegedAADRoles]::None; 
    static hidden [bool] $rolesLoaded = $false;

    hidden static [PSObject] GetCurrentAzContext()
    {
        if ($null -eq [AccountHelper]::currentAzContext)
        {
            throw ([SuppressedException]::new(("Cannot call this method before getting a sign-in context!"), [SuppressedExceptionType]::InvalidOperation))
        }
        return [AccountHelper]::currentAzContext
    }

    hidden static [void] ClearTenantContext()
    {
        [AccountHelper]::currentAzContext = $null;
        [AccountHelper]::GraphAccessToken = $null;
        [AccountHelper]::tenantInfoMsg = $null;
        [AccountHelper]::UserAADPrivRoles = [PrivilegedAADRoles]::None; 
        [AccountHelper]::rolesLoaded = $false; 
        [AccountHelper]::currentMgContext = $null;
        [AccountHelper]::currentMgUserObject = $null;
    }
    
    hidden static [PSObject] GetGraphToken()
    {
        if(-not [AccountHelper]::GraphAccessToken -or [AccountHelper]::GraphAccessToken.ExpiresOn.UtcDateTime -le [DateTime]::UtcNow)
        {
            $apiToken = $null
            $azContext = $null

            try {
                #Either throws or returns non-null
                $azContext = [AccountHelper]::GetCurrentAzContext()
                if($null -ne $azContext)
                {
                    $apiToken = Get-AzAccessToken -ResourceTypeName MSGraph
                    if ($null -eq $apiToken)
                    {
                        # NOTE: This is due a unusual behaviour of Azure Powershell module, where if the user has signed-in to the exact same tenant
                        # without using the -TenantId parameter, the Get-AzAccessToken will return null.
                        # To workaround this, we are trying to force a refresh and try again
                        [AccountHelper]::RefreshAzContext($azContext.Tenant.Id);
                        $apiToken = Get-AzAccessToken -ResourceTypeName MSGraph
                    }
                }  
            }
            catch {
                throw ([SuppressedException]::new("Could not acquire graph token for the user.`r`n$_", [SuppressedExceptionType]::Generic))
            }

            [AccountHelper]::GraphAccessToken = $apiToken
        }

        return [AccountHelper]::GraphAccessToken
    }

    # Can be called with $null (when tenantId is not specified by the user)
    hidden static [PSObject] GetCurrentAzContext($desiredTenantId)
    {
        if(-not [AccountHelper]::currentAzContext)
        {
            $azContext = Get-AzContext 
            #If there's no Az ctx, or it is indeterminate (user has no Azure subscription) or the tenantId in the azCtx does not match desired tenantId
            if ($null -eq $azContext -or $null -eq $azContext.Tenant -or (-not [string]::IsNullOrEmpty($desiredTenantId) -and $azContext.Tenant.Id -ne $desiredTenantId))
            {
                $azContext = [AccountHelper]::RefreshAzContext($desiredTenantId); 
            }
            
            
            [AccountHelper]::currentAzContext = $azContext
        }
        return [AccountHelper]::currentAzContext
    }

    hidden static [psobject] RefreshAzContext($desiredTenantId)
    {
        $azContext = Get-AzContext 
        if ($azContext) #If we have a context for another tenant, disconnect.
        {
            Disconnect-AzAccount -ErrorAction Stop
        }
        #Now try to fetch a fresh context.
        try {
            if ([string]::IsNullOrEmpty($desiredTenantId))
            {
                $azureContext = Connect-AzAccount -ErrorAction Stop;
            }
            else
            {
                $azureContext = Connect-AzAccount -ErrorAction Stop -Tenant $desiredTenantId;
            }
        }
        catch {
            Write-Warning "Could not acquire Azure context interactively, will fallback to device mode";
            if ([string]::IsNullOrEmpty($desiredTenantId))
            {
                $azureContext = Connect-AzAccount -ErrorAction Stop -UseDeviceAuthentication;
            }
            else
            {
                $azureContext = Connect-AzAccount -ErrorAction Stop -Tenant $desiredTenantId -UseDeviceAuthentication;
            }
        }
        $azContext = $azureContext.Context
        [AccountHelper]::currentAzContext = $azContext
        return [AccountHelper]::currentAzContext;
    }

    hidden static [PSObject] GetCurrentMgContext()
    {
        # TODO: Remove this method once migration to microsoft graph is complete
        if ($null -eq [AccountHelper]::currentMgContext)
        {
            throw ([SuppressedException]::new(("Cannot call this method before getting a sign-in context!"), [SuppressedExceptionType]::InvalidOperation))
        }

        [AccountHelper]::RefreshMgContextToken();
        return [AccountHelper]::currentMgContext;
    }

    hidden static [PSObject] GetCurrentMgContext($desiredTenantId) #Can be $null if user did not pass one.
    {
        $currMgCtx = [AccountHelper]::currentMgContext;

        if(-not $currMgCtx -or (-not [String]::IsNullOrEmpty($desiredTenantId) -and $desiredTenantId -ne $currMgCtx.TenantId))
        {
            [AccountHelper]::ClearTenantContext();

            $mgCtx = $null;
            $mgUserObj = $null;
           
            try {
                $tenantId = $null;
                $crossTenant = $false;

                if (-not [string]::IsNullOrEmpty($desiredTenantId))
                {
                    $tenantId = $desiredTenantId;
                }

                $azContext =  [AccountHelper]::GetCurrentAzContext($desiredTenantId);
                
                if ($null -ne $azContext -and $null -ne $azContext.Tenant) #Can be $null when a user has no Azure subscriptions.
                {
                    $nativeTenantId = $azContext.Tenant.Id;
                    if ($null -eq $tenantId) #No 'desired tenant' passed in by user
                    {
                        $tenantId = $nativeTenantId;
                    }
                    else
                    {
                        #Check if desiredTenant and native tenant are diff => this user is guest in the desired tenant
                        if ($nativeTenantId -ne $desiredTenantId)
                        {
                            $crossTenant = $true;
                        }
                    }
                }

                $graphToken = ConvertTo-SecureString ([AccountHelper]::GetGraphToken().Token) -AsPlainText -Force;
                Disconnect-MgGraph;
                Connect-MgGraph -AccessToken $graphToken -NoWelcome;
                $mgCtx = Get-MgContext;

                if (-not [String]::IsNullOrEmpty($desiredTenantId) -and $desiredTenantId -ne $mgCtx.TenantId)
                {
                    Write-Error "Mismatch between desired tenantId: $desiredTenantId and tenantId from login context: $($mgCtx.TenantId).`r`nYou may have mistyped the value of 'tenantId' parameter. Please try again!";
                    throw ([SuppressedException]::new("Mismatch between desired tenantId: $desiredTenantId and tenantId from login context: $($mgCtx.TenantId)", [SuppressedExceptionType]::Generic));
                }

                $upn = $mgCtx.Account;

                if ($null -eq $upn)
                {
                    # UPN is null for personal microsoft accounts
                    $upn = $azContext.Account.Id;
                }

                if (-not $crossTenant) 
                {
                    #Try direct match first
                    $mgUserObj = Get-MgUser -Filter "UserPrincipalName eq '$upn'";
                }

                if ($null -eq $mgUserObj)
                {
                    # Personal microsoft accounts also have user_mail@user_mail.onmicrosoft.com
                    #Cross-tenant, UPN is the mangled version e.g., joe_contoso.com#desiredtenant.com
                    $upnx = (($upn -replace '@', '_')+'#')
                    $filter = "startswith(UserPrincipalName,'" + $upnx + "')";
                    $mgUserObj = Get-MgUser -Filter $filter;
                }

                if ($null -eq $mgUserObj)
                {
                    Clear-AzContext -Force;
                    throw ([SuppressedException]::new("Could not find the user in the provided tenant, are you sure the right tenant id is passed?`n$_", [SuppressedExceptionType]::Generic));
                }
            }
            catch {
                throw ([SuppressedException]::new("Could not acquire an AAD tenant context!`r`n$_", [SuppressedExceptionType]::Generic))
            }

            [AccountHelper]::ScanType = [CommandType]::AAD
            [AccountHelper]::currentMgContext = $mgCtx
            [AccountHelper]::currentMgUserObject = $mgUserObj
            [AccountHelper]::tenantInfoMsg = "AAD Tenant Info: `n`tTenantId: $($mgCtx.TenantId)"
        }

        [AccountHelper]::RefreshMgContextToken();

        return [AccountHelper]::currentMgContext
    }

    static [void] RefreshMgContextToken()
    {
        if (-not [AccountHelper]::currentMgContext)
        {
            throw ([SuppressedException]::new("Cannot call this method before getting a sign-in context!", [SuppressedExceptionType]::InvalidOperation))
        }

        if (-not [AccountHelper]::GraphAccessToken -or [AccountHelper]::GraphAccessToken.ExpiresOn.UtcDateTime -le [DateTime]::UtcNow)
        {
            Write-Information "Refreshing Microsoft Graph token for the current session..."
            $graphToken = ConvertTo-SecureString ([AccountHelper]::GetGraphToken().Token) -AsPlainText -Force;
            Connect-MgGraph -AccessToken $graphToken -NoWelcome;
            [AccountHelper]::currentMgContext = Get-MgContext;
        }
    }

    static [string] GetCurrentTenantInfo()
    {
        return [AccountHelper]::tenantInfoMsg
    }

    static [string] GetCurrentSessionUser() 
    {
        $context = [AccountHelper]::GetCurrentMgContext(); 
        if ($null -ne $context) {
            return $context.Account.Id
        }
        else {
            return "NO_ACTIVE_SESSION"
        }
    }

    static [string] GetCurrentSessionUserObjectId() 
    {
        return ([AccountHelper]::GetCurrentMgUserObject()).Id;
    }

    hidden static [PSObject] GetCurrentMgUserObject()
    {
        return [AccountHelper]::currentMgUserObject;   
    }

    hidden static [PSObject] GetEnabledPrivRolesInTenant()
    {
        #Get subset of directory level roles that have been enabled in this tenant. (Not orgs enable all roles.)
        $enabledDirRoles = [array] (Get-MgDirectoryRole)

        #$srRole = $activeRoles | ? { $_.DisplayName -eq "Security Reader"}
        
        $apr = @()
        $enabledDirRoles | % {
            $ar = $_
        
            switch ($ar.DisplayName)
            {
                'Security Reader' { 
                    $apr += New-PrivRole -DisplayName 'Security Reader' -ObjectId $ar.Id -AADPrivRole ([PrivilegedAADRoles]::SecurityReader)
                }
        
                'User Account Administrator' { 
                    $apr += New-PrivRole -DisplayName 'User Account Administrator' -ObjectId $ar.Id -AADPrivRole ([PrivilegedAADRoles]::UserAccountAdmin)
                }
                 
                'Security Administrator' {
                    $apr += New-PrivRole -DisplayName 'Security Administrator' -ObjectId $ar.Id -AADPrivRole ([PrivilegedAADRoles]::SecurityAdmin)
                }
        
                'Company Administrator' {
                    $apr += New-PrivRole -DisplayName 'Company Administrator' -ObjectId $ar.Id -AADPrivRole ([PrivilegedAADRoles]::CompanyAdmin)
                }
            }
        }
        return $apr        
    } 

    #Returns a bit flag representing all roles we consider 'admin-like' that the user is currently a member of.
    #TODO: This only uses 'permanent' membership checks currently. Need to augment for PIM.
    static [PrivilegedAADRoles] GetUserPrivTenantRoles([String] $uid)
    {
        if ([AccountHelper]::rolesLoaded -eq $false)
        {
            $upr = [PrivilegedAADRoles]::None
            $apr = [AccountHelper]::GetEnabledPrivRolesInTenant()
            $apr | % {
                $pr = $_
                #Write-Host "$pr.AADPrivRole"
                $roleMembers = [array] (Get-MgDirectoryRoleMember -DirectoryRoleId $pr.ObjectId)
                #Write-Host "Count: $($roleMembers.Count)"
                if($roleMembers)
                {
                    $roleMembers | % { if ($_.Id -eq $uid) {$upr = $upr -bor $pr.AADPrivRole}}
                }
                
            }    

            [AccountHelper]::UserAADPrivRoles = $upr
            [AccountHelper]::rolesLoaded = $true
        }
        return [AccountHelper]::UserAADPrivRoles
    }

    #Is user a member of any directory role we consider 'admin-equiv.'?
    #Note: #TODO: This does not check for PIM-based role membership yet.
    static [bool] IsUserInAPermanentAdminRole()
    {
        $uid = ([AccountHelper]::GetCurrentMgUserObject()).Id
        $upr = [AccountHelper]::GetUserPrivTenantRoles($uid)
        return ($upr -ne [PrivilegedAADRoles]::None) 
    }


    hidden static [PSObject] GetCurrentAADAPIToken()
    {
        if(-not [AccountHelper]::AADAPIAccessToken)
        {
            $apiToken = $null
            
            $AADAPIGuid = [Constants]::AADAPIGuid

            #Try leveraging Azure context if available
            try {
                #Either throws or returns non-null
                $azContext = [AccountHelper]::GetCurrentAzContext()
                $tenantId = $null
                if ($null -ne $azContext.Tenant) #happens if user does not have any Azure subs.
                {
                    $tenantId = $azContext.Tenant.Id
                }
                else {
                    $tenantId = ([AccountHelper]::GetCurrentMgContext()).TenantId 
                }
                $apiToken = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate($azContext.Account, $azContext.Environment, $tenantId, $null, "Never", $null, $AADAPIGuid)
            }
            catch {
                Write-Warning "Could not get AAD API token for: $AADAPIGuid."
                throw ([SuppressedException]::new("Could not get AAD API token for: $AADAPIGuid.", [SuppressedExceptionType]::Generic))
            }

            [AccountHelper]::AADAPIAccessToken = $apiToken
            #TODO move to detailed log: Write-Host("Successfully acquired API access token for $AADAPIGuid")
        }
        return [AccountHelper]::AADAPIAccessToken
    }

    #TODO: Review calls to this. Should we have an AAD-version for it? Or just remove...
    static [string] GetAccessToken([string] $resourceAppIdUri, [string] $tenantId) 
    {
        return [AccountHelper]::GetAzureDevOpsAccessToken();
    }

    static [string] GetAzureDevOpsAccessToken()
    {
        # TODO: Handlle login
        if([AccountHelper]::currentAzureDevOpsContext)
        {
            return [AccountHelper]::currentAzureDevOpsContext.AccessToken
        }
        else
        {
            return $null
        }
    }

    static [string] GetAccessToken([string] $resourceAppIdUri) 
    {
        if([AccountHelper]::ScanType -eq [CommandType]::AzureDevOps)
        {
            return [AccountHelper]::GetAzureDevOpsAccessToken()
        }
        else {
            return [AccountHelper]::GetAccessToken($resourceAppIdUri, "");    
        }
        
    }

    static [string] GetAccessToken()
    {
        if([AccountHelper]::ScanType -eq [CommandType]::AzureDevOps)
        {
            return [AccountHelper]::GetAzureDevOpsAccessToken()
        }
        else {
            #TODO : Fix ResourceID
            return [AccountHelper]::GetAccessToken("", "");    
        }
    }
}