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.
Please note – if you are using Duo MFA for your 365 tenant you will have issues generating tokens. Duo does NOT give tokens appropriately to support the Secure Application Model. Your best option here is to generate a new service account that is excluded from your Duo Conditional Access policy and have it enforce with the Microsoft MFA instead.
I also strongly recommend that you use a separate global administrator account for each Secure Application Model application you create. This avoids conflicts that occur when using existing accounts, which may be in customer tenants as guest users and provides better tracing in audit logs.
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
Amazing, thank you
[…] need to have setup the Secure App Model (Thanks Gav for the superb […]
I have gone over this several times now, and I think I am missing something. When I go to run the test script. I get two errors
Could not log into the Graph API for tenant XXXX: The remote server returned (400) Bad Request
Incoke-RestMethod : The remote server returned an error (401) Unauthorized.
It appears I am missing a permission, but I am not exactly sure what.
I did have the following error Could not log into the Graph API for tenant XXXX: The remote server returned (400) Bad Request
Incoke-RestMethod : The remote server returned an error (401) Unauthorized.
After checking the account that was setup did not have the partner admin rights to access tenants.
I did have the following error Could not log into the Graph API for tenant XXXX: The remote server returned (400) Bad Request
Invoke-RestMethod : The remote server returned an error (401) Unauthorized.
After checking the account that was setup did not have the partner admin rights to access tenants.
Test script can’t login to Graph API and throwing 401? One additional step needed to grant all API Permissions. In Step 9, go to “API Permissions” and click “Grant admin consent for “
Thank you, Hubert. Completely missed that step. I’ve added this in
Hi folks,
same error here (Could not log into the Graph API for tenant…(401) unauthorized), everything already had consent but I clicked again anyway, and the error persists.
Maybe I’m missing another key point ?
I also already registered as a CSP partner in order to manage clients (I didn’t have the option in the first place), but I first wanted to test the app with my own tenant
Heads-up that it looks like the credentials that this creates will expire after 1 year. Might be a tricky surprise come October 2022.
It looks like the client secrets method can only go up to 24 months (vs Certificates, which can go like 100 years.)
[…] A configured M365 Partner SAM Application. To set this up I recommend you follow this guide https://www.gavsto.com/secure-application-model-for-the-layman-and-step-by-step/ […]
Hey Everyone,
I was just wondering what anyone has done to resolve the issue with:
Could not log into the Graph API for tenant xxxxx: The remote server returned an error: (400) Bad Request.
Unable to get users from tenant in Refresh Token Test. The detailed error information, if returned was: The remote server returned an error: (401) Unauthorized.
as well as:
Failed to Connect to Exchange for Tenant. The detailed error information, if returned was: [invalid_grant] AADSTS50076: Due to a configuration change made by your administrator, or because you moved to a new location, you must use multi-factor authentication to access ‘XXXXXXXXXXXXXXXX’.
I’ve tried all of the following and cant really understand what else to do.
1. I followed all the steps on this page. I also even made the user a global admin as well as a making sure all access was granted to the permissions.
2. When i ran “The Script” I got no errors and copied the codes down as i should’ve.
3. When I run the Test Script it sees all my Tenants but gives the above errors on all of them.
If anyone has had these issues and or can point me in a direction or what i might be doing wrong i would greatly appreciate it, I honestly don’t know what else to do.
and would love to get this working 😉
Hi Matt,
Did you manage to get this working. I’m battling with the exact same issue.
Thanks
The PowerShell test script is missing a critical line in the top section
$tenantid = “Your-Tenant-ID”
I did get the following error for 1 of our tenants: Could not log into the Graph API for tenant XXXX: The remote server returned (400) Bad Request followed by nable to get users from smk.ag in Refresh Token Test. The detailed error information, if returned was: Der Remoteserver hat einen Fehler zurückgegeben: (401) Nicht autorisiert.
I dont know why this just happens with one customer tenant. Any ideas?
Just followed this today and it worked without any issues. I do see this message below in the Azure App but everything in the test script verified successfully.
“Starting November 9th, 2020 end users will no longer be able to grant consent to newly registered multitenant apps without verified publishers.”
I’m in the same bout. The running th test script I get the following error. Any ideas?
Starting test of the standard Refresh Token
Attempting to retrieve an Access Token
Attempting to retrieve all tenants you have delegated permission to
1 tenants found, attempting to loop through each to test access to each individual tenant
Starting Refresh Token Loop Tests
Could not log into the Graph API for tenant xxxxxx-d23d-4c73-bfee-xxxxxxxx: The remote server returned an error: (400) Bad Request.
Unable to get users from xxxxxx.onmicrosoft.com in Refresh Token Test. The detailed error information, if returned was: The remote server returned an error: (401) Unauthorized.
I am having the same issue as many in the comments. I have tried several times to no success. The SAM app I made within CIPP works just fine but this does not and I have no idea what the issue is