Hello Everyone,

You’ve seen me banging on about GitHub – KelvinTegelaar/CIPP recently. I know a lot of people want to get involved and running on this but I know a number of people have struggled with the Secure Application Model setup of this – and honestly, it’s a particularly difficult process to follow, so I am going to walk you through it literally step by step.

Step 1 – Be a Global Admin in your tenant.

Assign admin roles the Microsoft 365 admin center – Microsoft 365 admin | Microsoft Docs

Step 2 – Make sure the account has delegated access to your client tenants.

Log in to the Partner Center (microsoft.com). In the top right, hit account settings:

In the left hand side menu, go to user Management

Find the user you want to give delegated access, and click on them. Tick this:

This account will now have delegated access to your clients tenants too.

Step 3 – Ensure that trusted IPs are not whitelisted in the Azure Portal

If they are, these need to be turned off while you provide this process. Navigate to Home – Microsoft Azure then open Azure Active Directory. In the left hand menu, go to Security then choose MFA in the left hand menu. Choose the option Configure > Additional cloud-based MFA settings. Untick the following:

Ensure the box is empty too. Please note the IP addresses you can see in the above screenshot are just Azure providing an example of what to put in the box.

Step 4 – Retrieve “the script”

The one at Connect to Exchange Online automated when MFA is enabled (Using the SecureApp Model) – CyberDrain. Copy it straight in to notepad and save it wherever you wish as SecureAppModel.ps1. Make sure you don’t copy line numbers.

Step 5 – Understand what the script is doing

The easiest way to explain this – we are creating a new application in Azure AD. We are going to give this application a Password. In many ways this functions like a normal user. That’s how I rationalise this in my head – I think of it as a user. It has its own permissions, its own password etc. In the script from lines 46, you can see it creating permissions. These attach to the app and allow it to access certain things. I will come back to this later. We then need to go through a consent process. Through this process, an access token will be requested from Azure Active Directory using an authorization code. The result returned from that request will include an access token, refresh token and additional information tied to the application we created earlier. The script follows a similar consent process to created a token specifically for Exchange. We then perform Admin consent for the app. I’ll come back to this later.

Step 6 – Prepare to run the Script

This is where I think most people go wrong with this process.

This script should only be run in PowerShell 5.1! It does not seem to work properly in PowerShell 7.

This script should not be run in PowerShell ISE! It does not seem to work properly in this application.

This script can be run in Visual Studio Code, but only when VSCode is running PowerShell 5.1. If it is not, don’t try.

To get this running, run a standard PowerShell window as administrator. Before I started, I did an
Install-Module AzureAD
and a
Install-Module PartnerCenter

Closed the window and re-open a fresh window as Administrator.

Browse to the location you had the script earlier. and type .\SecureAppModel.ps1

Step 7 – Run through the Script

You will be prompted for a display name. This is the name of the application that will get created. I suggest something like Company Name – Partner Management – SAM

You will then go through a series of prompts where you will need to authenticate. Please note, in some occasions these pop up windows may appear under other windows. There are 4 prompts from memory. You should be logging in as the Global Administrator each time. One of the options requires you to enter a code that will be given in the PowerShell terminal.

Any error during this process should be classed as catastrophic. If it does fail out when the application you should go and find the application you just created in Azure and delete it. See Step 9 to find it.

Step 8 – Securely store the codes the Script puts out

These tokens need to be kept safely secured. Treat them as you would a Global Admin password. If you want to store them in Azure Key Vault, see my article here: MSP PowerShell for Beginners Part 2: Securely store credentials, passwords, API keys and secrets – Gavsto.com – Everything ConnectWise Automate, LabTech, MSP and Reports

Step 9 – Look at the application you’ve created

Go to Home – Microsoft Azure and in the search bar at the top search for App Registrations. Once loaded, navigate to All Applications

Find the application you created and click it. This is the display name you chose earlier when running the script initially.

Note in Certificates and Secrets that this is where the apps client secret/application password is.

Note in Authentication that this is where the additional reply URIs are added in in line 83 in the script. They should look as follows:

Go in to API Permissions in the left hand menu. This is where the permissions are that grant this application to do the things that it does. You will see there are two types of permissions, and understanding them will help you wrap your head around what they are doing. You need to Grant Admin Consent for all of these permissions. Once all your permissions are in place you will need to press the button that says Grant Admin Consent for TENANT NAME. This will give you a green tick for each permission in the status column.

Delegated Permissions. I may well butcher this explanation, but this is how I rationalise it in my own head. These permissions are the ones that can reach our with user impersonation. For example, it empowers the application you have created to impersonate the Global Admin account you authorised the application with to reach out and do things in your clients tenants under the restrictions imposed in the API permission portion of the app.

Application Permissions. Again, I may butcher this but an application permission is something that is just the application itself authenticating. Because of how permissions currently work, apps like Teams and Sharepoint don’t allow that user impersonation of the Global Admin. This is because your client that you are managing does not have your global admin in its tenant. In situations like this, direct application permissions are used.

Step 10 – Rationalisation, Realisation and Expectation

I am by no means an expert in this. I find this stuff pretty complicated. Thank you to Kelvin Tegelaar who helped me ratify my knowledge on how these permissions are working. How he described Delegated Permissions and Application Permissions helped me concrete that knowledge. If you don’t quite understand this, don’t feel too bad. It’s difficult. Just remember one thing – the app you have created here and its API permissions are king. Delete the app, you undo the access given and render the tokens generated useless.

Delegate Permissions Explanation from Kelvin: So lets call your Global Admin “Gavin”, and your SAM application is “SAM”. SAM the app logs on and says “I want to logon, my source user is Gavin, my permissions are located on the application SAM”

Application Permissions Explanation from Kelvin: But Teams and Sharepoint don’t allow Gavin as a source, because Gavin is not in tenant “Customertenant.onmicrosoft.com”. So, at that moment we say “I am SAM, I want to logon as an application, and these are my permissions”

I believe changes are coming to Microsoft permissions in the not too distant future. Hopefully that will simplify this process significantly.

Step 11 – Test the permissions you have created

# Replace with your own variables
$ApplicationID = "YourAppID"
$applicationsecret = "YourAppSecret"
$refreshtoken = "YourRefreshToken"
$exchangerefreshtoken = "YourExchangeRefreshToken"
$MyTenant = "YourPartnerTenant.onmicrosoft.com"
# Do not edit below this line

function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $erefreshToken, $ReturnRefresh) {
    if (!$scope) { $scope = 'https://graph.microsoft.com/.default' }

    $AuthBody = @{
        client_id     = $ApplicationId
        client_secret = $ApplicationSecret
        scope         = $Scope
        refresh_token = $eRefreshToken
        grant_type    = "refresh_token"
                    
    }

    if ($null -ne $AppID -and $null -ne $erefreshToken) {
        $AuthBody = @{
            client_id     = $appid
            refresh_token = $eRefreshToken
            scope         = $Scope
            grant_type    = "refresh_token"
        }
    }

    if (!$tenantid) { $tenantid = $env:tenantid }
    $AccessToken = (Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ErrorAction Stop)
    if ($ReturnRefresh) { $header = $AccessToken } else { $header = @{ Authorization = "Bearer $($AccessToken.access_token)" } }

    return $header
}
function Connect-graphAPI {
    [CmdletBinding()]
    Param
    (
        [parameter(Position = 0, Mandatory = $false)]
        [ValidateNotNullOrEmpty()][String]$ApplicationId,
         
        [parameter(Position = 1, Mandatory = $false)]
        [ValidateNotNullOrEmpty()][String]$ApplicationSecret,
         
        [parameter(Position = 2, Mandatory = $true)]
        [ValidateNotNullOrEmpty()][String]$TenantID,
 
        [parameter(Position = 3, Mandatory = $false)]
        [ValidateNotNullOrEmpty()][String]$RefreshToken
 
    )
    Write-Verbose "Removing old token if it exists"
    $Script:GraphHeader = $null
    Write-Verbose "Logging into Graph API"
    try {
        if ($ApplicationId) {
            Write-Verbose "   using the entered credentials"
            $script:ApplicationId = $ApplicationId
            $script:ApplicationSecret = $ApplicationSecret
            $script:RefreshToken = $RefreshToken
            $AuthBody = @{
                client_id     = $ApplicationId
                client_secret = $ApplicationSecret
                scope         = 'https://graph.microsoft.com/.default'
                refresh_token = $RefreshToken
                grant_type    = "refresh_token"
                
            }
             
        }
        else {
            Write-Verbose "   using the cached credentials"
            $AuthBody = @{
                client_id     = $script:ApplicationId
                client_secret = $Script:ApplicationSecret
                scope         = 'https://graph.microsoft.com/.default'
                refresh_token = $script:RefreshToken
                grant_type    = "refresh_token"
                
            }
        }
        $AccessToken = (Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ErrorAction Stop).access_token
 
        $Script:GraphHeader = @{ Authorization = "Bearer $($AccessToken)" }
    }
    catch {
        Write-Host "Could not log into the Graph API for tenant $($TenantID): $($_.Exception.Message)" -ForegroundColor Red
    }
 
}
 
write-host "Starting test of the standard Refresh Token" -ForegroundColor Green

try {
    write-host "Attempting to retrieve an Access Token" -ForegroundColor Green
    Connect-graphAPI -ApplicationId $applicationid -ApplicationSecret $applicationsecret -RefreshToken $refreshtoken -TenantID $MyTenant
}
catch {
    $ErrorDetails = if ($_.ErrorDetails.Message) {
        $ErrorParts = $_.ErrorDetails.Message | ConvertFrom-Json
        "[$($ErrorParts.error)] $($ErrorParts.error_description)"
    }
    else {
        $_.Exception.Message
    }
    Write-Host "Unable to generate access token. The detailed error information, if returned was: $($ErrorDetails)" -ForegroundColor Red
}

try {
    write-host "Attempting to retrieve all tenants you have delegated permission to" -ForegroundColor Green
    $Tenants = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/contracts?`$top=999" -Method GET -Headers $script:GraphHeader).value
}
catch {
    $ErrorDetails = if ($_.ErrorDetails.Message) {
        $ErrorParts = $_.ErrorDetails.Message | ConvertFrom-Json
        "[$($ErrorParts.error)] $($ErrorParts.error_description)"
    }
    else {
        $_.Exception.Message
    }
    Write-Host "Unable to retrieve tenants. The detailed error information, if returned was: $($ErrorDetails)" -ForegroundColor Red
}

# Setup some variables for use in the foreach. Pay no attention to the man behind the curtain....
$TenantCount = $Tenants.Count
$IncrementAmount = 100 / $TenantCount
$i = 0
$ErrorCount = 0

write-host "$TenantCount tenants found, attempting to loop through each to test access to each individual tenant" -ForegroundColor Green
# Loop through every tenant we have, and attempt to interact with it with Graph
foreach ($Tenant in $Tenants) {
    Write-Progress -Activity "Checking Tenant - Refresh Token" -Status "Progress -> Checking $($Tenant.defaultDomainName)" -PercentComplete $i -CurrentOperation TenantLoop
    If ($i -eq 0) { Write-Host "Starting Refresh Token Loop Tests" }
    $i = $i + $IncrementAmount

    try {
        Connect-graphAPI -ApplicationId $applicationid -ApplicationSecret $applicationsecret -RefreshToken $refreshtoken -TenantID $tenant.customerid
    }
    catch {
        $ErrorDetails = if ($_.ErrorDetails.Message) {
            $ErrorParts = $_.ErrorDetails.Message | ConvertFrom-Json
            "[$($ErrorParts.error)] $($ErrorParts.error_description)"
        }
        else {
            $_.Exception.Message
        }
        Write-Host "Unable to connect to graph API for $($Tenant.defaultDomainName). The detailed error information, if returned was: $($ErrorDetails)" -ForegroundColor Red
        $ErrorCount++
        continue
    }


    try {
        $Result = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users" -Method GET -Headers $script:GraphHeader).value
    }
    catch {
        $ErrorDetails = if ($_.ErrorDetails.Message) {
            $ErrorParts = $_.ErrorDetails.Message | ConvertFrom-Json
            "[$($ErrorParts.error)] $($ErrorParts.error_description)"
        }
        else {
            $_.Exception.Message
        }
        Write-Host "Unable to get users from $($Tenant.defaultDomainName) in Refresh Token Test. The detailed error information, if returned was: $($ErrorDetails)" -ForegroundColor Red
        $ErrorCount++
    }
    
}

Write-Host "Standard Graph Refresh Token Test: $TenantCount total tenants, with $ErrorCount failures"
Write-Host "Now attempting to test the Exchange Refresh Token"

# Setup some variables for use in the foreach. Pay no attention to the man behind the curtain....
$j = 0
$ExcErrorCount = 0

foreach ($Tenant in $Tenants) {
    Write-Progress -Activity "Checking Tenant - Exchange Refresh Token" -Status "Progress -> Checking $($Tenant.defaultDomainName)" -PercentComplete $j -CurrentOperation TenantLoop
    If ($j -eq 0) { Write-Host "Starting Exchange Refresh Token Test" }
    $j = $j + $IncrementAmount

    try {
        $upn = "[email protected]"
        $tokenvalue = ConvertTo-SecureString (Get-GraphToken -AppID 'a0c73c16-a7e3-4564-9a95-2bdf47383716' -ERefreshToken $ExchangeRefreshToken -Scope 'https://outlook.office365.com/.default' -Tenantid $tenant.defaultDomainName).Authorization -AsPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
        $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($tenant.defaultDomainName)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection -ErrorAction Continue
        $session = Import-PSSession $session -ea Silentlycontinue -AllowClobber -CommandName "Get-OrganizationConfig"
        $org = Get-OrganizationConfig
        $null = Get-PSSession | Remove-PSSession
    }
    catch {
        $ErrorDetails = if ($_.ErrorDetails.Message) {
            $ErrorParts = $_.ErrorDetails.Message | ConvertFrom-Json
            "[$($ErrorParts.error)] $($ErrorParts.error_description)"
        }
        else {
            $_.Exception.Message
        }
        Write-Host "Tenant: $($Tenant.defaultDomainName)-----------------------------------------------------------------------------------------------------------" -ForegroundColor Yellow
        Write-Host "Failed to Connect to Exchange for $($Tenant.defaultDomainName). The detailed error information, if returned was: $($ErrorDetails)" -ForegroundColor Red        
        $ExcErrorCount++
    }
}

Write-Host "Exchange Refresh Token Test: $TenantCount total tenants, with $ExcErrorCount failures"
Write-Host "All Tests Finished"

This script performs a number of tests attempting to utilise and make connections/access tokens for both standard Refresh Tokens and Exchange Tokens. If it errors in this script, it won’t work in CIPP.

Final

I hope you found this helpful. A big thank you to Kelvin Tegelaar from Home – CyberDrain – I’ve drawn on a lot of his expertise and code to write this article.

If you followed this and it didn’t work, please let me know. I’d like to keep this article updated with the “Gotchas” of the Secure Application Model.

You can find more information about permission types here Microsoft identity platform scopes, permissions, & consent | Microsoft Docs

You can find more information about the Secure Application Model and how it works here Partner Center PowerShell | Microsoft Docs