Microsoft Entra ID
- Get app details and grant permissions to app registration
- Regularly clean up MEID devices with automation
- Regularly clean up MEID guest user with automation
- Notify license shortage with automation
- Notify client app secret expiration with automation
- Get App Registrations by User Principal Name
- Change MFA Phone via Graph API
- Move Azure Subscriptions between Microsoft Entra ID tenants
Get app details and grant permissions to app registration
Provision App Registration
App Registrations are containers that allow user-independent permission assignment and are therefore ideally suited for automation. App Registrations can be created in Azure Portal → Microsoft Entra ID → App Registrations. App Registrations should be preferred to service accounts whenever possible.
The following variables are used to authenticate to the Graph API using application permissions. The ClientSecret must not be stored as clear text in scripts or applications under any circumstances, but must be stored in designated containers (e.g. Azure Runbook Credential Store, Azure Key Vault or Windows Credential Store).
Variables used
$TenantID: This is the identity of the tenant, which is unique.
$ClientID: The ClientID can be used to uniquely identify the App Registration.
$ClientSecret: The ClientSecret expires every max. 24 months (2 years) and is like the password for the App Registration.
Read ClientID & TenantID
The ClientID & TenantID can be read out on the start page of the App Registration itself.
Create client secret
For the ClientSecret you have to switch to the "Certificates & Secrets" tab. A ClientSecret can be added via "New client secret". Then a name can be given there. Attention: Afterwards the ClientSecret is valid for 24 months (2 years) and expires after a certain time. In addition, the value is only displayed once. Save the ClientSecret as the first step in your password storage solution and note the expirationdate.
Grant permissions
As soon as the authentication via the app registration with the three values works, the permissions have to be assigned. This is a very tricky step, as an app could practically become a global administrator and overwrite or delete any tenant configurations.
The permissions on App Registrations for PowerShell Scripts must always be set to Application-Permissions, because the actions should be executed as App-Context.
Find the required permissions
The best way to find the required permissions is to visit the Microsoft Docs page for Graph API: Microsoft Graph REST API v1.0 endpoint reference - Microsoft Graph v1.0 | Microsoft Learn
Assign permissions
Once it has been determined which permissions need to be assigned, this can be done in Azure Portal → Microsoft Entra ID → App Registration → API permissions.
First, "Add a permission" must be selected and then Microsoft Graph as an API resource.
Then it is important that "Application permissions" is selected. There you can then search for the corresponding permission from the Microsoft Docs page and add it using "Add permissions".
Consent assigned permissions
Now that the permission has been added thanks to the previous step, the last thing to do is to check it and then approve it. This can only be done with the "Global Administrator" role, as the app will then receive this permission forever and this is like assigning a role. The person who has Global Administrator must click on "Grand admin consent for <companyname>" in the app so that the permissions also become active.
Afterwards, the app can be used with the assigned permissions.
Regularly clean up MEID devices with automation
Requirements: Create an App Registration with Directory.ReadWrite.All and Device.ReadWrite.All. For logging you need to have an Azure Function to create an API to centralize logs in an Azure Log Analytics Workspace.
This automation gets all Microsoft Entra ID Devices via the Microsoft Graph API and checks everyone for their latest sign in. If the login was more than 6 months ago, the device will be deleted from the Microsoft Entra ID. Afterwards, the key figures for the deleted devices and the errors are passed to the Azure Function API, where they are then stored in the corresponding Log Analytics Workspace.
Use case
This automation concept is perfectly suited for large enterprises which don't have an overview of their old Microsoft Entra ID Devices. Authentication in the direction of Microsoft Entra ID is user-independent with an app registration. This makes it perfect for automated and regular execution of this code. In combination with an Azure Runbook that runs weekly, the Microsoft Entra ID environment can be simplified significantly.
PowerShell script
This PowerShell script needs the AzureAD Module, an app registration with the appropriate permissions and an Azure Function to collect the logs and write them to an Azure Log Analytics Workspace.
First install the AzureAD Module.
Install-Module AzureAD
After you have installed the modules and organized the prerequisites (App Registration with Directory.ReadWrite.All and Device.ReadWrite.All permission and an Azure Function API for centralizing the logs) you can fill the four variables to the PowerShell code and run the script.
Import-Module AzureAD
$tenantId= "<yourtenantid>"
$ClientId= "<yourclientidofappregistration>"
$ClientSecret = "<yourclientsecretofappregistration>"
$url="<yoururlofcentralizedlogcollectionfunction>"
$expirationThresholdMonths = 6
Import-Module AzureAD
Function Send-Logs(){
param (
[String]$LogType,
[Hashtable]$LogBodyList
)
$LogBodyJSON = @"
{
"logtype": "$LogType",
"logbody": {}
}
"@
$LogBodyObject = ConvertFrom-JSON $LogBodyJSON
Foreach($Log in $LogBodyList.GetEnumerator()){
$LogBodyObject.logbody | Add-Member -MemberType NoteProperty -Name $log.key -Value $log.value
}
$Body = ConvertTo-JSON $LogBodyObject
$Response = Invoke-Restmethod -uri $url -Body $Body -Method POST -ContentType "application/json"
return $Response
}
$Body = @{
"tenant" = $TenantId
"client_id" = $ClientId
"scope" = "https://graph.microsoft.com/.default"
"client_secret" = $ClientSecret
"grant_type" = "client_credentials"
}
$Params = @{
"Uri" = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
"Method" = "Post"
"Body" = $Body
"ContentType" = "application/x-www-form-urlencoded"
}
$AuthResponse = Invoke-RestMethod @Params
$Header = @{
"Authorization" = "Bearer $($AuthResponse.access_token)"
}
$azurePassword = ConvertTo-SecureString $clientSecret -AsPlainText -Force
$psCred = New-Object System.Management.Automation.PSCredential($ClientId , $azurePassword)
Connect-AzAccount -Credential $psCred -TenantId $TenantId -ServicePrincipal
$context = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile.DefaultContext
$aadToken = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate($context.Account, $context.Environment, $context.Tenant.Id.ToString(), $null, [Microsoft.Azure.Commands.Common.Authentication.ShowDialog]::Never, $null, "https://graph.windows.net").AccessToken
Connect-AzureAD -AadAccessToken $aadToken -AccountId $context.Account.Id -TenantId $context.tenant.id
$Result = Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/v1.0/devices" -Header $Header
$Devices = $Result.value
while ($Result.'@odata.nextLink') {
$Result = Invoke-RestMethod -Uri $Result.'@odata.nextLink' -Headers $Header
$Devices += $Result.value
}
$oldDevices = @()
$failedDateDevices = @()
foreach($inactiveDevice in $Devices){
try{
$Date = Get-Date($inactiveDevice.approximateLastSignInDateTime)
if($Date -lt ((Get-Date).AddMonths(-$expirationThresholdMonths))){
$oldDevices += $inactiveDevice
}
} catch {
$failedDateDevices += $inactiveDevice
}
}
Write-Warning "Date Errors: $($failedDateDevices.count)"
$failedDeleteDevices = @()
$deletedDevices = @()
foreach($Device in $oldDevices){
try{
Remove-AzureADDevice -ObjectId $Device.id
Write-Output "Deleted: $($Device.displayName)"
$deletedDevices += $Device
}catch{
$failedDeleteDevices += $Device
}
}
Write-Warning "Delete Errors: $($failedDeleteDevices.count)"
$Logs = @{
"deletesuccess" = "$($deletedDevices.count)"
"dateerrors"="$($failedDateDevices.count)"
"deleteerrors"="$($failedDeleteDevices.count)"
}
Send-Logs -LogType "CleanUpMEIDDevicesExecutions" -LogBodyList $Logs
Regularly clean up MEID guest user with automation
Requirements: Create an App Registration with AuditLog.Read.All and User.ReadWrite.All. For logging you need to have an Azure Function to create an API to centralize logs in an Azure Log Analytics Workspace.
This automation gets all Microsoft Entra ID Guest User via the Microsoft Graph API and checks everyone for their latest sign in. If the login was more than 6 months ago, the user account will be deleted from the Microsoft Entra ID. Afterwards, the key figures for the deleted devices and the errors are passed to the Azure Function API, where they are then stored in the corresponding Log Analytics Workspace.
Use case
This automation concept is perfectly suited for large enterprises which don't have an overview of all of their Microsoft Entra ID Guests. Authentication in the direction of Microsoft Entra ID is user-independent with an app registration. This makes it perfect for automated and regular execution of this code. In combination with an Azure Runbook that runs weekly, the Microsoft Entra ID environment can be simplified significantly.
PowerShell script
This script has no dependency on any modules and is based on all standard powershell modules. After you organized the prerequisites (App Registration with AuditLog.Read.All and User.ReadWrite.All permission and an Azure Function API for centralizing the logs) you can fill the four variables to the PowerShell code and run the script.
$tenantId= "<yourtenantid>"
$ClientId= "<yourclientidofappregistration>"
$ClientSecret = "<yourclientsecretofappregistration>"
$url="<yoururlofcentralizedlogcollectionfunction>"
$expirationThresholdDays = 180
$NotificateOnDays = @(1,5,10,30)
$SenderUPN = "<yoursenderupn>"
$Subject = "<yourinformationsuccess>"
Function Send-Logs(){
param (
[String]$LogType,
[Hashtable]$LogBodyList
)
$LogBodyJSON = @"
{
"logtype": "$LogType",
"logbody": {}
}
"@
$LogBodyObject = ConvertFrom-JSON $LogBodyJSON
Foreach($Log in $LogBodyList.GetEnumerator()){
$LogBodyObject.logbody | Add-Member -MemberType NoteProperty -Name $log.key -Value $log.value
}
$Body = ConvertTo-JSON $LogBodyObject
$Response = Invoke-Restmethod -uri $url -Body $Body -Method POST -ContentType "application/json"
return $Response
}
function Send-Mail {
param (
[String]$SenderUPN,
[Array]$Recipients,
[Array]$CCRecipients,
[String]$Subject,
[String]$Content,
[String]$DisplayName,
[Int]$deletionDay,
[String]$UPN
)
$Content = @"
Hello $DisplayName
<yourmailbody>
"@
$MailBodyJSON = @"
{
"message": {
"subject": "$Subject",
"body": {
"contentType": "Text",
"content": ""
},
"toRecipients": [],
"ccRecipients": []
},
"saveToSentItems": "true"
}
"@
$MailBodyObject = ConvertFrom-Json $MailBodyJSON
$MailBodyObject.message.body.content = $Content
Foreach($Recipient in $Recipients){
$RecipientBodyJson = @"
{
"emailAddress": {
"address": "$Recipient"
}
}
"@
$RecipientBodyObject = ConvertFrom-JSON $RecipientBodyJson
$MailbodyObject.message.toRecipients += $RecipientBodyObject
}
Foreach($CCRecipient in $CCRecipients){
$CCRecipientBodyJson = @"
{
"emailAddress": {
"address": "$CCRecipient"
}
}
"@
$CCRecipientBodyObject = ConvertFrom-JSON $CCRecipientBodyJson
$MailbodyObject.message.ccRecipients += $CCRecipientBodyObject
}
$MailoutputbodyJson = ConvertTo-JSON $MailbodyObject -Depth 10
Write-Host "Sending Mail to $Recipient and $CCRecipient (CC)."
Invoke-RestMethod -Method Post -Uri "https://graph.microsoft.com/v1.0/users/$SenderUPN/sendMail" -Headers $Header -ContentType "application/json" -Body ([System.Text.Encoding]::UTF8.GetBytes($MailoutputbodyJson))
}
$Body = @{
"tenant" = $TenantId
"client_id" = $ClientId
"scope" = "https://graph.microsoft.com/.default"
"client_secret" = $ClientSecret
"grant_type" = "client_credentials"
}
$Params = @{
"Uri" = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
"Method" = "Post"
"Body" = $Body
"ContentType" = "application/x-www-form-urlencoded"
}
$AuthResponse = Invoke-RestMethod @Params
$Header = @{
"Authorization" = "Bearer $($AuthResponse.access_token)"
}
$Result = Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$select=userPrincipalName,signInActivity,displayName" -Header $Header
$GuestUsers = $Result.value
while ($Result.'@odata.nextLink') {
$Result = Invoke-RestMethod -Uri $Result.'@odata.nextLink' -Headers $Header
$GuestUsers += $Result.value
}
$failedDateGuestUsers = @()
$failedDeletedGuestUsers = @()
$failedNotifiedGuestUsers = @()
$successfullynotifiedGuestUsers = @()
$successfullydeletedGuestUsers = @()
foreach($GuestUser in $GuestUsers){
try{
$NonInteractiveDayspan = [math]::Round($(New-TimeSpan -Start $(Get-Date $GuestUser.signInActivity.lastNonInteractiveSignInDateTime) -End $(Get-Date)).TotalDays)
$InteractiveDayspan = [math]::Round($(New-TimeSpan -Start $(Get-Date $GuestUser.signInActivity.lastSignInDateTime) -End $(Get-Date)).TotalDays)
} catch {
$failedDateGuestUsers += $GuestUser
}
if ($NonInteractiveDayspan -lt $InteractiveDayspan) {
$deletionDay = $expirationThresholdDays - $NonInteractiveDayspan
} else {
$deletionDay = $expirationThresholdDays - $InteractiveDayspan
}
if($deletionDay -in $NotificateOnDays -or $deletionDay -lt 0){ # DELETE -or $deletionDay -lt 0
if($deletionDay -le 0){
$deletionDay = "paar"
}
try{
$GuestUserMail = (Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$($GuestUser.id)?`$select=mail" -Header $Header).mail
Send-Mail -Recipients @("$GuestUserMail") -SenderUPN $SenderUPN -DisplayName $GuestUser.displayName -UPN $GuestUser.userPrincipalName -DeletionDay $deletionDay -Subject $Subject
$successfullynotifiedGuestUsers += $GuestUser
}
catch{
$failedNotifiedGuestUsers += $GuestUser
}
}
if(($deletionDay -lt -5)){
try{
$Result = Invoke-RestMethod -Method DELETE -Uri "https://graph.microsoft.com/v1.0/users/$($GuestUser.id)" -Header $Header
$successfullydeletedGuestUsers += $GuestUser
}catch{
$failedDeletedGuestUsers += $GuestUser
}
}
}
Write-Warning "Date Errors: $($failedDateUsers.count)"
Write-Warning "Delete Errors: $($failedDeletedGuestUsers.count)"
Write-Warning "Notification Errors: $($failedNotifiedGuestUsers.count)"
Write-Output "Notified GuestUsers: $($successfullynotifiedGuestUsers.count)"
Write-Output "Deleted GuestUsers: $($successfullydeletedGuestUsers.count)"
$Logs = @{
"deletesuccess"="$($successfullydeletedGuestUsers.count)"
"notificationsuccess"="$($successfullynotifiedGuestUsers.count)"
"notificationerrors"="$($failedNotifiedGuestUsers.count)"
"deleteerrors"="$($failedDeletedGuestUsers.count)"
"dateerrors"="$($failedDateUsers.count)"
}
Send-Logs -LogType "CleanUpMEIDGuestUserExecutions" -LogBodyList $Logs
Notify license shortage with automation
Requirements: Create an App Registration with LicenseAssignment.ReadWrite.All permissions and a client secret.
With this automation all licenses and their usage are evaluated via Graph API. A message is posted in a team channel when a certain amount of usage of a license is reached.
Use case
This automation helps large organizations with many Microsoft 365 licenses to keep track. If this automation is carried out regularly, interruptions in operation and overbooked licenses can be noticed at an early stage. To run such scripts on a regular basis, an Azure Runbook is a good choice.
Teams webhook notification
To send a notification to a Teams Channel you can use a webhook. To create a webhook for a channel you can rightclick on a channel and then choose "Connectors".
There you can then configure a new incoming webhook:
You have to name the webhook first. This name will be displayed as the sender of the message. You can upload an image which will then be used as a profile picture of the sender. After that you can click "create". This will return an URL which then can be used to send a payload message. This will then look like that:
PowerShell script
This PowerShell script needs an App Registration and the according LicenseAssignment.ReadWrite.All Graph API permission. This script uses the three Azure Automation variables "$tenantid", "$clientid" and "$clientsecret" as well as the perviously created "$WebhookURI", which should be added before.
$tenantId=Get-AutomationVariable -Name "<nameoftenantidrunbookvariable>"
$ClientId=Get-AutomationVariable -Name "<nameofclientidrunbookvariable>"
$CredentialObject=Get-AutomationPSCredential -Name '<nameofclientsecretrunbookcredentials>'
$ClientSecret = $CredentialObject.GetNetworkcredential().password
$WebhookURI = ""
$PercentageAlert = "98"
$Body = @{
"tenant" = $TenantId
"client_id" = $ClientId
"scope" = "https://graph.microsoft.com/.default"
"client_secret" = $ClientSecret
"grant_type" = "client_credentials"
}
$Params = @{
"Uri" = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
"Method" = "Post"
"Body" = $Body
"ContentType" = "application/x-www-form-urlencoded"
}
$AuthResponse = Invoke-RestMethod @Params
$Header = @{
"Authorization" = "Bearer $($AuthResponse.access_token)"
}
$AllLicenses = Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/v1.0/subscribedSkus" -Header $Header
Foreach($License in $AllLicenses.value){
if($License.prepaidUnits.enabled -ge 50){
try{
$LicensePercentage = ($License.consumedUnits/$License.prepaidUnits.enabled*100)
}
catch{
$null
}
if($LicensePercentage -ge $PercentageAlert){
$CurrentTime = Get-Date
$JsonBody = @"
{
"@context": "https://schema.org/extensions",
"@type": "MessageCard",
"themeColor": "880808",
"title": "License warning: $($License.skuPartNumber)",
"text": "License has more than 98% allocations. Please order new licenses in order not to jeopardize the operation.<br><br>Licensename: $($License.skuPartNumber) <br><br>Licenses available: $($License.prepaidUnits.enabled - $License.consumedUnits) <br><br> Licenses total: $($License.prepaidUnits.enabled) <br> Licenses assigned: $($License.consumedUnits) <br><br> Time of the evaluation: $($CurrentTime.addHours(2))<br><br> More details about the license: https://admin.microsoft.com/#/licensedetailpage/$($License.skuId)",
}
"@
Invoke-RestMethod -Method Post -Body $JsonBody -Uri $WebhookURI -Header @{"content-type" = "application/json; charset=UTF-8"}
}
}
}
Notify client app secret expiration with automation
Requirements: Create an App Registration with Application.Read.All permissions and a client secret.
With this automation all client secrets and their expiration dates are evaluated via Graph API. A message is posted in a team channel when a client secret is about to expire.
Use case
This automation helps large organizations with many Microsoft Entra ID App Registrations to keep track of expiration dates of their Client Secrets. If this automation is carried out regularly, interruptions in operation and expired client secrets can be noticed at an early stage. To run such scripts on a regular basis, an Azure Runbook is a good choice.
Teams webhook notification
To send a notification to a Teams Channel you can use a webhook. To create a webhook for a channel you can rightclick on a channel and then choose "Connectors".
There you can then configure a new incoming webhook:
You have to name the webhook first. This name will be displayed as the sender of the message. You can upload an image which will then be used as a profile picture of the sender. After that you can click "create". This will return an URL which then can be used to send a payload message.
PowerShell script
This PowerShell script needs an App Registration and the according Application.Read.All Graph API permission. This script uses the three Azure Automation variables "$tenantid", "$clientid" and "$clientsecret" as well as the perviously created "$WebhookURI", which should be added before.
$tenantId=Get-AutomationVariable -Name "<nameoftenantidrunbookvariable>"
$ClientId=Get-AutomationVariable -Name "<nameofclientidrunbookvariable>"
$CredentialObject=Get-AutomationPSCredential -Name '<nameofclientsecretrunbookcredentials>'
$ClientSecret = $CredentialObject.GetNetworkcredential().password
$WebhookURI = "<yourwebhookuri>"
$NotifyOnDays = @(1,2,3,4,5,10,15,20,30)
$DeleteSecretIfExpiredForDays = 30
$Body = @{
"tenant" = $TenantId
"client_id" = $ClientId
"scope" = "https://graph.microsoft.com/.default"
"client_secret" = $ClientSecret
"grant_type" = "client_credentials"
}
$Params = @{
"Uri" = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
"Method" = "Post"
"Body" = $Body
"ContentType" = "application/x-www-form-urlencoded"
}
$AuthResponse = Invoke-RestMethod @Params
$Header = @{
"Authorization" = "Bearer $($AuthResponse.access_token)"
}
function Send-ToTeams {
Param(
$SecretOB
)
$Body = @{
"@context" = "https://schema.org/extensions"
"@type" = "MessageCard"
"themeColor" = "880808"
"title" = "Secret of App Registration $($SecretOB.AppName) expires in $($SecretOB.ExpiresInXDays)!"
"text" = "<ul><li>App: $($SecretOB.AppName)</li><li>AppId: $($SecretOB.AppID)</li><li>SecretId: $($SecretOB.SecretID)</li><li>Expirationdate: $($SecretOB.ExpiryDate) (in $($SecretOB.ExpiresInXDays) days)</li></ul>"
}
$JsonBody = $Body | ConvertTo-JSON
Invoke-RestMethod -Method Post -Body $JsonBody -Uri $WebhookURI -Headers @{"content-type" = "application/json; charset=UTF-8"} | out-null
}
$AllApps = @()
$Response = Invoke-RestMethod -Method Get -Uri "https://graph.microsoft.com/v1.0/applications" -Headers $Header
$AllApps = $Response.value
while ($Response.'@odata.nextLink') {
$Response = Invoke-RestMethod -Method Get -Uri $Response.'@odata.nextLink' -Headers $Header
$AllApps += $Response.value
}
Write-Output "$($Allapps.Count) Apps were discovered"
$AllSecrets = @()
foreach ($App in $AllApps) {
foreach ($AppSecret in $App.passwordCredentials) {
$ExpiresInXDays = $(New-TimeSpan -Start $(Get-Date) -End $(Get-Date $AppSecret.endDateTime)).TotalDays
switch ($ExpiresInXDays) {
{ $_ -lt 30 } {$Status = "Expires Soon" }
{ $_ -lt 0 } {$Status = "Expired" }
Default {$Status = "Ok" }
}
$SecretOB = [PSCustomObject]@{
AppID = $App.AppID
AppName = $App.DisplayName
SecretID = $AppSecret.KeyID
ExpiryDate = $AppSecret.endDateTime
ExpiresInXDays = [math]::Round($ExpiresInXDays)
Status = $Status
}
$AllSecrets += $SecretOB
if ($SecretOB.ExpiresInXDays -in $NotifyOnDays) {
[array]$AppOwners = $(Invoke-RestMethod -Method get -Uri "https://graph.microsoft.com/v1.0/applications/$($App.ID)/owners" -Headers $Header).value
Write-Warning "Secret $($SecretOB.SecretID) on App $($SecretOB.AppID) expires in $($SecretOB.ExpiresInXDays) days."
if ($AppOwners) {
foreach ($AdminOwner in $AppOwners | Where-Object { $_.userPrincipalName -like "A_" }) {
$SAM = $AdminOwner.userPrincipalName.Split("@")[0].replace("A_","")
$RegularUser = $(Invoke-RestMethod -Method GET -uri "https://graph.microsoft.com/v1.0/users?`$filter=startswith(MailNickName, '$SAM')" -Headers $Header).Value
$AppOwners += $RegularUser
}
if ([array]$AppOwners.Mail) {
Send-ToTeams -SecretOB $SecretOB
} else {
Send-ToTeams -SecretOB $SecretOB
}
} else {
Send-ToTeams -SecretOB $SecretOB
}
} elseif ($SecretOB.ExpiresInXDays -lt -$DeleteSecretIfExpiredForDays) {
if ($($App.passwordCredentials | Where-Object { $_.endDateTime -gt $(get-date) }).Count -ge 1) {
Write-Output "Secret $($SecretOB.SecretID) on App $($SecretOB.AppID) expired $(-$SecretOB.ExpiresInXDays) Days ago and can be deleted. Client has at least one Secret that does not expire whithin the next $DeleteSecretIfExpiredForDays days."
} else {
Write-Output "Secret $($SecretOB.SecretID) on App $($SecretOB.AppID) expired $(-$SecretOB.ExpiresInXDays) Days ago and can be deleted"
}
$SecretDeletionBody = @"
{
"keyId": "$($SecretOB.SecretID)"
}
"@
#Invoke-RestMethod -Method Post -Uri "https://graph.microsoft.com/v1.0/applications/$($SecretOB.AppID)/removePassword" -Body $SecretDeletionBody -Headers $Header
}
}
}
If you want to automatically delete old app registrations that have expired for more than a defined number of days ($DeleteSecretIfExpiredForDays) and no longer have any active secrets, you can show the time 118. This will permanently delete all app registrations that have expired client secrets.
Get App Registrations by User Principal Name
Graph API permission "Application.Read.All" or Cloud Application Administrator Role.
This guide explains how you can get all App Registrations by a certain user. This can be handy when someone leaves a company and it needs to be evaluated which app registration was maintained by that person.
Preparations
For this script to work you need the permission to list the App Registrations. This needs the Application.Read.All permission. The access token can be acquired either by delegated or application permissions.
This is a manual for application based token acquisition: Create application acc... | LNC DOCS (lucanoahcaprez.ch)
And this one is for user based tokens: Create user access tok... | LNC DOCS (lucanoahcaprez.ch)
Evaluate App Registration Owners
This is the script that evaluates the owners of the app registrations and the app registrations of the corresponding user are stored in the $UsersApplication variable. In advance, the UPN of the user to be evaluated must be stored in the $UserPrincipleName variable.
$AccessToken = "<yourazureadaccesstoken>"
$UserPrincipalName = "<userprincipalnametosearchfor>"
$Header = @{
"Authorization" = "Bearer $($AccessToken)"
}
$Params = @{
"Method" = "Get"
"Uri" = "https://graph.microsoft.com/v1.0/applications"
"Headers" = $Header
"ContentType" = "application/json"
}
$Result = Invoke-RestMethod @Params
$AllApplications = $Result.value
while ($Result.'@odata.nextLink') {
$Result = Invoke-RestMethod -Uri $Result.'@odata.nextLink' -Headers $Header
$AllApplications += $Result.value
}
$UsersApplication = @()
Foreach($Application in $AllApplications){
$Params.Uri = "https://graph.microsoft.com/v1.0/applications/$($Application.id)/owners?`$select=id,userPrincipalName"
$ApplicationInfo = (Invoke-RestMethod @Params).value
if($ApplicationInfo.userPrincipalName -eq $UserPrincipalName){# -and $ApplicationInfo.userPrincipalName.count -eq 1){ #This can be displayed if you want to serach only for apps where the user is the only owner
$UsersApplication += $Application
}
}
Change MFA Phone via Graph API
This automation sets the primary mobile number as MFA method according to a UPN. This can be used, for example, if from an internal store or user interface (e.g. ServiceNow) the users should automatically set the MFA method a first time. For example, this code can be placed in an Azure Runbook or Azure Function and executed via a trigger.
Requirements
To execute this code you need to create an App Registration and add the Permissions "UserAuthenticationMethod.ReadWrite.All". How you can create an App Registration and how you get the variables "TenantId", "ClientId" and "ClientSecret" with the according values, you can view this manual: Get app details and gr... | LNC DOCS (lucanoahcaprez.ch)
PowerShell Code
This code must be filled with the correct variables for everything to work. On the one hand it needs the standard variables for the Graph Authentication: "$TenantID", "$ClientID", "$Clientsecret". And on the other hand the variables "$Email" and "$PhoneNumber" are used to locate the user to whom the mobile number should be set as MFA method.
$Email = "<youremail>"
$PhoneNumber = "<yourphonenumber>"
# Filter empty spaces
if($PhoneNumber.contains(" ")){
$PhoneNumber = $PhoneNumber.replace(" ","")
}
$TenantId = "<yourtenantid>"
$ClientId = "<yourappregistrationid>"
$ClientSecret = "<yourclientsecret>"
$Body = @{
"tenant" = $TenantId
"client_id" = $ClientId
"scope" = "https://graph.microsoft.com/.default"
"client_secret" = $ClientSecret
"grant_type" = "client_credentials"
}
$Params = @{
"Uri" = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
"Method" = "Post"
"Body" = $Body
"ContentType" = "application/x-www-form-urlencoded"
}
$AuthResponse = Invoke-RestMethod @Params
$Headers = @{
"Authorization" = "Bearer $($AuthResponse.access_token)"
}
# Get User ID By UPN
$UsersResponse = Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$email" -ContentType "Application/Json" -Headers $Headers
$UserId = $UsersResponse.id
# Change Phone Number for MFA
$PhoneMethod = @"
{
"phoneNumber":"$PhoneNumber",
"phoneType":"mobile"
}
"@
$MFAResponse = Invoke-RestMethod -Method PUT -Uri "https://graph.microsoft.com/beta/users/$UserId/authentication/phoneMethods/3179e48a-750b-4051-897c-87b9720928f7" -ContentType "Application/Json" -Body $PhoneMethod -Headers $Headers
Start-Sleep 30
# Compare Phone Numbers
$MFAMethod = Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/beta/users/$UserId/authentication/phoneMethods" -ContentType "Application/Json" -Headers $Headers
$AzurePhoneNumber = $MFAMethod.value.phoneNumber.Replace(" ","")
if($AzurePhoneNumber -eq $PhoneNumber){
Write-Output "success"
}else{
Write-Output "Failed to compare Azure Phone Number to Input from SNOW."
}
Move Azure Subscriptions between Microsoft Entra ID tenants
Prerequisites: Permissions to invite new guest users in source tenant. Permissions to grant Owner permissions to subscription in source tenant. Permissions to accept guest invitations in target tenant.
This guide shows you how to move an Azure subscription from one Microsoft Entra ID tenant to another. This can be useful if business requirements or other company structures have changed and you do not want to rebuild the resources.
Instructions using Entra ID guest user
The procedure is quite simple. The sourcetenant must simply invite the administrator account on the target tenant as a guest. Once the invitation has been accepted, the user from the target tenant can see the subscription in the Azure subscription and transfer it to their target tenant via the Azure portal.
Limitations
There are various limitations. Here is a list of some that are already known in the community. It certainly makes sense to search the official Microsoft pages here.
- Role based access control roles cannot be taken over. This is actually logical, as the roles, groups and users are very likely to be completely different in the source and target tenants. In addition, the elements certainly do not have the same IDs, which makes it impossible to migrate these role assignments.
- If billing is resolved at management group level, this is still handled in the source tenant and must be changed manually afterwards.
This list is not exhaustive and migrating subscriptions is always associated with risk.
How it is done
First you have to invite the user account from the target tenant as guest user in the source tenant.
Log in to the source tenant and make sure that you have all permissions to invite guest users. You must also be able to adjust the IAM permissions on the subscription that you want to migrate.
Invite new guest user
You can now invite the user account in the source tenant as described in this guide: Quickstart: Add a guest user and send an invitation - Microsoft Entra External ID | Microsoft Learn
Make sure that the user has accepted the invitation. Check the guest users state under "Invitation state":
Add Azure role based access to guest user
If the user is successfully registered in the Entra ID, the subscription can be opened in the Azure portal and a new role assignment can be made under Access control.
Make sure to add the correct privileged administrator role:
Make sure to select the corresponding user account of the target tenant. Under "Conditions" select the second property to grant all admin privileges:
Then you can create the role assignment.
Switch account and make sure to use the invited user account of the target tenant from now on.
Start subscription migration
Switch directory to the source tenant and do the following steps:
Go to subscriptions and there you should see the subscription of the source tenant that you want to move. Open the subscription and make sure you are in the Overview blade.
Now you can choose "Change directory":
You can then select the target tenant in this dialog. You must confirm that RBAC roles cannot be transferred.
Wait for the confirmation message that the subscription is being migrated. It can then take up to 10 minutes before you can reuse all resources.
Switch to the target tenant and wait for the completion of the migration. Make sure once the migration is complete, make sure that everything works as expected.
Migration of billing ownership
Once the previous steps have been completed, you can do the following. The billing accounts for the subscription are still in the old tenant at this stage and will remain there unless a migration is carried out.
If this migration is to be carried out, we recommend working through these instructions: