Using Delegated Partner Access with Exchange Online PowerShell

The delegated admin privileges available to partner organizations such as MSPs and CSPs are fantastic to allow admin access without going to the trouble of maintaining a list of admin accounts for each customer. Along with just rotating accounts as people move through the business, ensuring the correct security is in place and requiring licensing for things like PIM becomes a headache for a large customer base.

Using delegated admin via the Microsoft 365 Partner Center allows service desk personnel to gain access via an already secured account (with MFA etc.) without using a customer specific account. In the past this was great for small tasks but anything that required scripting required a real admin account in the destination tenancy.

With the general release early this year of the Exchange Online PowerShell (v2) Module, a great feature that I find now everyone is using currently, is the ability to connect to an Exchange Online organization using Delegated Access Permissions (i.e. Partner account).

Exchange Online PowerShell V2

For those who don’t have the module installed and are still using the old Exchange Module, download it now with the below command:

Install-Module ExchangeOnlineManagement

Once installed, the Connect-ExchangeOnline cmdlet will allow you to connect to any organization with an appropriate account using modern authentication.

Some of the REST based cmdlets available in this module can really improve the speed and stability of your PowerShell scripts and I have previously uploaded some examples on this blog.

Connecting to a Customer Tenancy

For a delegated partner organization, a small change to the connection command will allow a delegated connection to customer tenancies:

Connect-ExchangeOnline -DelegatedOrganization contoso.onmicrosoft.com 

Simply specify the organization you wish to connect to and as long as you have delegated permissions, you’re in!

This functionality simplifies the support model for a lot of partners who often will have scripted processes for things like permissions and licensing. Not needed to provision all potential service desk analysts with an admin account and additional security is a nice touch.

Delegated Admin is also available in several other Office 365 related PowerShell Modules in different ways. In the future I might pull them all together into one post.

Send Azure AD Guest User Invitations via Graph API

The built in controls in Azure AD for Guest User invitations are great for most cases. You can lock down guest invitations to specific users or groups and even specific recipient domains. When we look at more highly secure tenancies however, we often see requirements for approval flows or custom workflows to be associated with Guest User invitations.

This isn’t something available in the native GUIs but, as usual, that doesn’t mean it’s not possible! This entire process can be automated and built into a simple Graph API call. We can then expand out what we build into a Logic App/Power Automate flow. Using the different Microsoft technologies, the entire process can be customized to an endless degree. Even non-Microsoft technologies such as Service Management tools can kick off the process and integrate nicely into Azure Automation etc.

To help people (including myself on several projects) to build out their custom processes around guest user invitations, I’ve built the below PowerShell function to provision a guest user invitation via Graph API

In this post, we’ll walk through setting up the environment for the function and how to run it.

Setting up an app registration

The first thing we need for our Graph call is an application registration. This can be set up in Azure AD and assigned the permissions as below:

Configure the new app registration with a name. A redirect URL of http://localhost can be set as we don’t need redirect.

Under API Permissions, add and grant User.Invite.All, User.ReadWrite.All and Directory.ReadWrite.All permissions. Depending on how you are connecting this can be delegated or application permissions but as I need to automate the calls, I’ve selected application permissions. Ensure to grant consent on the permissions after adding them

Finally, create a Client Secret and take note of it for use in the Graph Connection

Parameters Required

Before we can run the function, we need to collect some information. We need to gather the below information about our tenant:

  • The Client ID of the application registration we created above
  • The Tenant ID (Directory ID) from Azure AD
  • The Client Secret we noted when we created the application registration
  • A URL to redirect users to after completion, this isn’t mandatory and the default is https://myapps.microsoft.com

Once we have all this information, we can import the function by running:

import-module <Filepath to PS1 file>\graph-Send-GuestInvitation.ps1

Once imported, we can run our function with the below command:

SendGuestInvitation -UserEmail <Guest user email> -ClientSecret <Your Client Secret> -ClientID <Your Client ID> -TenantID <Your Tenant ID>

When the function is run, it will return an invitation object. This object contains the details of the invitation as well as the “inviteRedeemURL” which can be sent to the guest to redeem their invitation in a more custom, branded email from an offical corporate email address.

This function can be found on GitHub here

Note, this code is for illustration purposes and not fit for running in production. Please ensure you understand any code you are running in a production environment.

function GetGraphToken {
    # Azure AD OAuth Application Token for Graph API
    # Get OAuth token for a AAD Application (returned as $token)
    <#
    .SYNOPSIS
    This function gets and returns a Graph Token using the provided details
    

    .PARAMETER clientSecret
    -is the app registration client secret

    .PARAMETER clientID
    -is the app clientID

    .PARAMETER tenantID
    -is the directory ID of the tenancy
    
    #>
    Param(
        [parameter(Mandatory = $true)]
        [String]
        $ClientSecret,
        [parameter(Mandatory = $true)]
        [String]
        $ClientID,
        [parameter(Mandatory = $true)]
        [String]
        $TenantID

    )

    
    
    # Construct URI
    $uri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
     
    # Construct Body
    $body = @{
        client_id     = $clientId
        scope         = "https://graph.microsoft.com/.default"
        client_secret = $clientSecret
        grant_type    = "client_credentials"
    }
     
    # Get OAuth 2.0 Token
    $tokenRequest = Invoke-WebRequest -Method Post -Uri $uri -ContentType "application/x-www-form-urlencoded" -Body $body -UseBasicParsing
     
    # Access Token
    $token = ($tokenRequest.Content | ConvertFrom-Json).access_token
    return $token
}

Function SendGuestInvitation {
    <#
    .SYNOPSIS
    This function gets Graph Token from the GetGraphToken Function and uses it to request a new guest user
    
    .PARAMETER UserEmail
    -UserEmail is the email address of the requested user
    
    .PARAMETER clientSecret
    -is the app registration client secret

    .PARAMETER clientID
    -is the app clientID

    .PARAMETER tenantID
    -is the directory ID of the tenancy
    
    .PARAMETER tenantID
    -A URL to redrect to after the invitation is redeemed
    #>
    Param(
        [parameter(Mandatory = $true)]
        [String]
        $UserEmail,
        [parameter(Mandatory = $true)]
        [String]
        $ClientSecret,
        [parameter(Mandatory = $true)]
        [String]
        $ClientID,
        [parameter(Mandatory = $true)]
        [String]
        $TenantID,
        [parameter(Mandatory = $false)]
        [String]
        $RedirectURL = "https://myapps.microsoft.com"

    )

    $token = GetGraphToken -ClientSecret $ClientSecret -ClientID $ClientID -TenantID $TenantID

    $apiUri = 'https://graph.microsoft.com/beta/invitations/'
    $body = "{'invitedUserEmailAddress': '$UserEmail','inviteRedirectUrl': '$RedirectURL'}"
    
    $invitation = (Invoke-RestMethod -Headers @{Authorization = "Bearer $($token)" } -Uri $apiUri -Method Post -ContentType 'application/json' -Body $body)

    Return $invitation
}

PowerShell Script To Migrate Users From Skype On Premises to Microsoft Teams

With the functionality available in Microsoft Teams, lot of organizations are focusing moving from an on-premises Skype for Business server to full Microsoft Teams Voice. This requires user accounts to be migrated to Teams and assigned Full Teams Only Mode.

I’ve put together a handy script to allow the migration from Skype to be user friendly and seamless from a admin perspective. The below script will give an export of all users and allow an admin to select one or more users and process them for migration. The script should be imported using “import-module TeamsMove.ps1” into the Skype for Business PowerShell Module and then the process can be kicked off by running the function “Move-ToTeams”. The script can be found on GitHub here.

Warning: This script may not run as expected in your environment and is used for illustration. It may need to be modified for your exact use case. Ensure you understand the steps and test before running in your environment.

##This function allows us to select users who are still homed in Skype for business
function move-toTeams-selectusers{
    $i = 1
    ##Allow selection of one or multiple users
    $users = (Get-CsUser -ResultSize unlimited | ?{$_.registrarPool -notlike $null} | Out-GridView -PassThru -Title "Select One or more users to migrate")
    
    ##Writing Progress to Screen
    Write-host you selected $users.count users

    ##check if it's array
    if($users -is [array]){
        foreach($User in $users){
        
             write-host Moving user $i of $users.count : $user.SipAddress

             Move-ToTeams-performMove $user
             $i++
        }
    }else{
            
            write-host Moving single user: $users.SipAddress
            Move-ToTeams-performMove $users

     }
}

function Move-ToTeams-performMove{
    <#
    .SYNOPSIS
    This function performs the move from Skype on Premises to Teams
    
    .DESCRIPTION
    This function accepts a user account and moves the user to Teams
    
    .PARAMETER useridentity
    Takes a user in for processing by the migration process
        
    .NOTES
    General notes
    #>
    param($useridentity)

    ##Removes conferencing policy from user account
     Grant-CsConferencingPolicy -PolicyName "No Dial-in" -Identity $useridentity.identity
     
     ##Create Dialog
     $a = new-object -comobject wscript.shell 

     ##Prompt for if calling policy should be enabled
     $intAnswer = $a.popup("Should " + $useridentity.sipaddress + " be enabled for outbound calling", 0,"Outbound Calls",4) 
    
    ##IF yes
    If ($intAnswer -eq 6) { 
        ##warn admin to ensure license is assigned
       $a.popup("Enabling Calling Policy for " + $useridentity.sipaddress + ", make sure they are licensed for Calling or this will fail!") 
        ##Enable outbound calling
        Set-CsUser -Identity $useridentity.identity -EnterpriseVoiceEnabled:$True
    }##Else no 
    else { 
        $a.popup("Removing Calling Policy from " + $useridentity.sipaddress + ", license can be removed if assigned") 
        ##Disable outbound calling
        Set-CsUser -Identity $useridentity.identity -EnterpriseVoiceEnabled:$False -Confirm:$false
    } 
  
    Try{
        ##Moves user to Teams
        Move-CsUser -Identity $UserIdentity.identity -Target sipfed.online.lync.com -MoveToTeams -Credential $credentials -HostedMigrationOverrideUrl $url -ErrorAction Stop -Confirm:$false
        write-host $UserIdentity.identity completed successfully -ForegroundColor Green

    }catch{
    #Catch and notify admin of error
    $ErrorMessage = $_.Exception.Message

    write-host Failed Migrating $useridentity.sip with error $ErrorMessage

    }

}


function Move-toTeams{



    ##Adding Teams endpoint URL
    $url="https://admin0e.online.lync.com/HostedMigration/hostedmigrationService.svc"
    
    ##If credentials dont exist, prompt for them
    if(!($credentials)){

        $credentials = Get-Credential -Message "Please enter credentials for an Office 365 admin with onmicrosoft UPN"

    }

    move-toTeams-selectusers

}

Graph API & PowerShell: Function To Use A Refresh Token To Renew An Access Token

In the process of updating my library of standard scripts and functions, I’ve taken the opportunity to change a lot of them to use Graph API rather than legacy EWS or PowerShell Modules. Using Graph helps to make them much more efficient and flexible and a lot of the functionality that I need is there anyway.

One challenge I came across with Graph was when running with delegated permissions and avoiding authenticating constantly for unattended scripts. When running as an app we can pipe in our App Registration details and away we go but when you bring delegated permissions and MFA into the loop, that becomes a challenge.

I’ve put together the below function (located here on GitHub) to allow me to use a refresh token and renew my access token to keep access alive. When using access tokens, they will by default last for an hour, needing to be renewed after that. Refresh tokens (which last 14 days) can then be used to renew this access token and get a new refresh token in the process.

The function itself takes in the following values:

-Token: The existing refresh token

-tenantID: The ID of your tenant (tenant.onmicrosoft.com)

-ClientID: Client ID of your App Reg

-Secret: The secret of your app reg

-Scope: A comma delimited list of your access scope

It will then pass back a new token object complete with renewed access and refresh tokens. I generally store these tokens in Azure Key Vault and update them at refresh time. This means my apps can refresh tokens when they need to and save them securely, only accessing at run time.

function RefreshAccessToken{

<#

.SYNOPSIS

Refreshes an access token based on refresh token



.RETURNS

Returns a refreshed access token



.PARAMETER Token

-Token is the existing refresh token



.PARAMETER tenantID

-This is the tenant ID eg. domain.onmicrosoft.com



.PARAMETER ClientID

-This is the app reg client ID



.PARAMETER Secret

-This is the client secret



.PARAMETER Scope

-A comma delimited list of access scope, default is: "Group.ReadWrite.All,User.ReadWrite.All"



#>

Param(

[parameter(Mandatory = $true)]

[String]

$Token,

[parameter(Mandatory = $true)]

[String]

$tenantID,

[parameter(Mandatory = $true)]

[String]

$ClientID,

[parameter(Mandatory = $false)]

[String]

$Scope = "Group.ReadWrite.All,User.ReadWrite.All",

[parameter(Mandatory = $true)]

[String]

$Secret

)



$ScopeFixup = $Scope.replace(',','%20')

$apiUri = "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token"

$body = "client_id=$ClientID&scope=$ScopeFixup&refresh_token=$Token&redirect_uri=http%3A%2F%2Flocalhost%2F&grant_type=refresh_token&client_secret=$Secret"

write-verbose $body -Verbose

$Refreshedtoken = (Invoke-RestMethod -Uri $apiUri -Method Post -ContentType 'application/x-www-form-urlencoded' -body $body )



return $Refreshedtoken



}