Find Expiring Enterprise Applications and App Registrations… With Microsoft Graph!

ELEVATE KEYBOARD

Link to the git repo if you are into that https://github.com/VinceCarbone/ExpiringEntraApps

I’ve covered this previously, however since then I’ve gotten much better at using PowerShell, and have decided to re-address this using Microsoft Graph since Microsoft basically doesn’t want you using the older Cmdlets anymore. So let’s goooooooo

Brief summary

You have an Azure tenant full of Enterprise Applications and App Registrations. These configurations have corresponding certificates and / or client secrets, each with an expiration date.

Outside of either manually configuring an email address for notifications on every Enterprise App, or manually going and checking the App Registrations, you basically have no way to get in front of this problem. That’s where I come in.

First things first, you will need to have an app registration created for this with the Directory.Read.All API Permission assigned. You will also need a certificate generated and assigned to the app registration. This same certificate will need to be installed on the local computer under the context of whatever user will be running the script

Assuming you’ve got an app registration you can use, let’s get to the PowerShell

This first bit of the script just sets up a few variables. You’ll need your Tenant ID, your App (Client) ID for the service principal you’ll be using, and the thumbprint of the certificate you’re using to authenticate

$tenantid = ""
$clientid = ""
$CertificateThumbprint = ""

This next line will connect to Microsoft Graph using the information you provided above.

Connect-MgGraph -TenantId $tenantid -ClientId $clientid -CertificateThumbprint (Get-Item -Path "Cert:\CurrentUser\My\$CertificateThumbprint").Thumbprint

Now we’ll query for all the Enterprise Applications and store it as the $EnterpriseApps array. You can see I’ve specified specific attributes to retrive as well.

$EnterpriseApps = Get-MgServicePrincipal -All -Property appid,createdDateTime,displayname,id,keycredentials,notes,preferredsinglesignonmode,serviceprincipaltype

Next we’ll query for all the App Registrations and store that as the $AppRegistrations array. Again I’m telling it specific attributes to pull.

$AppRegistrations = Get-MgApplication -all -property appid,approles,createddatetime,displayname,id,notes,passwordcredentials,keycredentials

This next big chunk of code is looking through each Enterprise Application and finding its associated App Registration and adding the result to the $Results array. This array will contain expiring certificates, expiring secrets, owners, and notes information.

You trying to read this next chunk
$Results = @(
    ForEach($EnterpriseApp in $EnterpriseApps){
        $Owners = @(Get-MgServicePrincipalOwner -ServicePrincipalID $enterpriseapp.ID)
        $EnterpriseOwners = $null
        $EnterpriseOwners = @(
            ForEach($owner in $owners){                
                Try{Get-MgUser -UserID $owner.id -erroraction Stop | Select-Object -ExpandProperty DisplayName} Catch {$EnterpriseApps | Where-Object Id -eq $owner.id | Select-Object -ExpandProperty DisplayName} # Apparently some can be owned by other enterprise apps, so I added this try/catch
            }
        )
        $EnterpriseOwnersCombined = $EnterpriseOwners -join ","
        $MatchingAppRegistration = $AppRegistrations | Where-Object AppId -eq $enterpriseapp.AppId

        If($MatchingAppRegistration){
            $Owners = @(Get-MgApplicationOwner -ApplicationId $MatchingAppRegistration.ID)
            $AppOwners = $null
            $AppOwners = @(
                ForEach($owner in $owners){
                    Try{Get-MgUser -UserID $owner.id -erroraction Stop | Select-Object -ExpandProperty DisplayName} Catch {$EnterpriseApps | Where-Object Id -eq $owner.id | Select-Object -ExpandProperty DisplayName} # Apparently some can be owned by other enterprise apps, so I added this try/catch
                }
            )
            $AppOwnersCombined = $AppOwners -join ","
        } Else {$AppOwnersCombined = $null}

        [pscustomobject]@{
            EnterpriseApp = $enterpriseapp.DisplayName
            EnterpriseObjectID = $enterpriseapp.Id
            EnterpriseApplicationID = $enterpriseapp.AppId
            EnterpriseCreated = (Get-Date "$($enterpriseapp.additionalproperties.createdDateTime)").ToString("yyyy-MM-dd") # the createdDateTime key is CASE SENSITIVE specifically for the enterprise app
            EnterpriseCertificateExpiration = (($enterpriseapp | Select-Object -ExpandProperty keycredentials | Select-Object -ExpandProperty enddatetime | Sort-Object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)
            EnterprisePreferredSingleSignOnMode = $enterpriseapp.PreferredSingleSignOnMode
            EnterpriseServicePrincipalType = $enterpriseapp.serviceprincipaltype
            EnterpriseOwners = $EnterpriseOwnersCombined
            EnterpriseNotes = $enterpriseapp.notes
            AppRegistration = $MatchingAppRegistration.DisplayName
            AppObjectID = $MatchingAppRegistration.Id
            AppApplicationID = $MatchingAppRegistration.AppId
            AppCreated = If($MatchingAppRegistration.CreatedDateTime){(Get-Date "$($MatchingAppRegistration.CreatedDateTime)").ToString("yyyy-MM-dd")}Else{$null}
            AppClientSecretExpiration = (($MatchingAppRegistration | Select-Object -ExpandProperty passwordcredentials | Select-Object -ExpandProperty enddatetime | Sort-Object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)
            AppCertificateExpiration = (($MatchingAppRegistration | Select-Object -ExpandProperty keycredentials | Select-Object -ExpandProperty enddatetime |sort-object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)
            AppOwners = $AppOwnersCombined
            AppNotes = $MatchingAppRegistration.notes
        }
    }
)

This next chunk of code is similar, but it is going through a second time and adding all the App Registrations that do not have a corresponding Enterprise Application. It adds the additional results to the existing $Results array.

$Results += @(
    ForEach($AppRegistration in $AppRegistrations){
        If(($enterpriseapps | Where-Object appid -eq $appRegistration.appid) -eq $null){
            $Owners = @(Try{Get-MgApplicationOwner -ApplicationId $appRegistration.appid -ErrorAction Stop}Catch{$null})
            $AppOwners = $null
            $AppOwners = @(
                ForEach($owner in $owners){
                    Try{Get-MgUser -UserID $owner.id -erroraction Stop | Select-Object -ExpandProperty DisplayName}Catch{$null}
                }
            )
            $AppOwnersCombined = $AppOwners -join ","
            [pscustomobject]@{
                EnterpriseApp = $null
                EnterpriseObjectID = $null
                EnterpriseApplicationID = $null
                EnterpriseCreated = $null
                EnterpriseCertificateExpiration = $null
                EnterprisePreferredSingleSignOnMode = $null
                EnterpriseServicePrincipalType = $null
                EnterpriseOwners = $null
                EnterpriseNotes = $null
                AppRegistration = $AppRegistration.DisplayName
                AppObjectID = $AppRegistration.Id
                AppCreated = (Get-Date "$($AppRegistration.CreatedDateTime)").ToString("yyyy-MM-dd")
                AppApplicationID = $AppRegistration.AppId
                AppClientSecretExpiration = (($appregistration | Select-Object -ExpandProperty passwordcredentials | Select-Object -ExpandProperty enddatetime | Sort-Object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)# -join "," 
                AppCertificateExpiration = (($appregistration | Select-Object -ExpandProperty keycredentials | Select-Object -ExpandProperty enddatetime |sort-object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)# -join ","
                AppOwners = $AppOwnersCombined
                AppNotes = $AppRegistration.Notes
            }
        }
    }
)

The $Results array contains everything. If this is all you need, you can export this to a CSV using the following. The code below will drop a CSV on the desktop that’s stamped with a date and time so it’s always unique.

$date = get-date -format yyyyMMddHHmmss
$Results | Export-Csv -path "$env:USERPROFILE\desktop\EntraApps_All_$date.csv" -NoTypeInformation -Force

OR, if you want to narrow the results down, you can do something like this before exporting. The example below will only include results that will be expiring in the next 45 days. This means Enterprise Apps and App Registrations that either don’t have an expiration at all, or are expiring at a later date (later than 45 days), they will not be included. This is probably more useful to run as a scheduled task so you can always see what the next round of expirations will be for your environment. The example below actually exports two CSVs, the full list of everything, and the smaller

$expiration = 45
$ExpiringSoon = $results | Where-Object EnterpriseServicePrincipalType -ne "ManagedIdentity" | Where-Object {($_.EnterpriseCertificateExpiration -ne $null -and $_.EnterpriseCertificateExpiration -lt (get-date).adddays($expiration)) -or ($_.AppClientSecretExpiration -ne $null -and $_.AppClientSecretExpiration -lt (get-date).adddays($expiration)) -or ($_.AppCertificateExpiration -ne $null -and $_.AppCertificateExpiration -lt (get-date).adddays($expiration))}

$date = get-date -format yyyyMMddHHmmss
$ExpiringSoon | Export-Csv -path "$env:USERPROFILE\desktop\EntraApps_ExpiringSoon_$date.csv" -NoTypeInformation -Force

I find both CSVs useful. You can get a full list of the apps in your tenant, and the ones that are expiring soon.

The CSV will contain a number of headers. Half start with “Enterprise” and the other half start with “App”. This indicates the information was pulled from either the Enterprise Application or the App Registration. The fields in bold will contain any pending expiration info

  • EnterpriseApp
  • EnterpriseObjectID
  • EnterpriseApplicationID
  • EnterpriseCreated
  • EnterpriseCertificateExpiration
  • EnterprisePreferredSingleSignOnMode
  • EnterpriseServicePrincipalType
  • EnterpriseOwners
  • EnterpriseNotes
  • AppRegistration
  • AppObjectID
  • AppApplicationID
  • AppCreated
  • AppClientSecretExpiration
  • AppCertificateExpiration
  • AppOwners
  • AppNotes

One thing to note: currently some of the logic in the above script will limit the number of returned client secrets / certificates for each entry to only one. It will be the most recent one (the newest). So for example, if you have an app that has two secrets, one is expired and one isn’t, this script will only return the newest non-expired one. The reason for this is the output becomes very difficult to parse if you have an app with multiple expiration dates.

The goal here is simply to identify apps where the newest secret is either already expired or will be soon. If you have an app with two secrets, and one is valid and one isn’t, well… that app technically should still be working assuming you’re using the valid secret. That said, I’d recommend manually going through and cleaning up expired secrets to get your tenant in order.

One more thing. In your resulting output, you’ll see a field called EnterpriseServicePrincipalType. In my experience, I’ve seen this say a few things, including Legacy, ManagedIdentity, and Application. Having looked through each of these types in my own tenant, I’ve determined the following:

  • Legacy – This seemingly is a SharePoint related app. There is a specific process to update and remove secrets for these. You cannot do this via the Entra Admin portal. You can read up on this here
  • ManagedIdentity – You cannot update these secrets. It’s annoying to see an expired secret in the output, but this isn’t controlled by your tenant. Safe to ignore these
  • Application – These are the ones you can do something about in the Entra Admin Portal

Here’s the full script if you just want to copy and paste it. This will export both CSVs to your desktop.

# Defines the Tenant and App registration details
$tenantid = ""
$clientid = ""
$CertificateThumbprint = ""


# Connects to Microsoft Graph
Connect-MgGraph -TenantId $tenantid -ClientId $clientid -CertificateThumbprint (Get-Item -Path "Cert:\CurrentUser\My\$CertificateThumbprint").Thumbprint

# Finds all Enterprise Apps
$EnterpriseApps = Get-MgServicePrincipal -All -Property appid,createdDateTime,displayname,id,keycredentials,notes,preferredsinglesignonmode,serviceprincipaltype

# Finds all App Registrations
$AppRegistrations = Get-MgApplication -all -property appid,approles,createddatetime,displayname,id,notes,passwordcredentials,keycredentials

# Correlates Enterprise Apps with their corresponding App Registration
$Results = @(
    ForEach($EnterpriseApp in $EnterpriseApps){

        # Retrieves owners for Enterprise Apps
        $Owners = @(Get-MgServicePrincipalOwner -ServicePrincipalID $enterpriseapp.ID)
        $EnterpriseOwners = $null
        $EnterpriseOwners = @(
            ForEach($owner in $owners){                
                Try{Get-MgUser -UserID $owner.id -erroraction Stop | Select-Object -ExpandProperty DisplayName} Catch {$EnterpriseApps | Where-Object Id -eq $owner.id | Select-Object -ExpandProperty DisplayName} # Apparently some can be owned by other enterprise apps, so I added this try/catch
            }
        )
        $EnterpriseOwnersCombined = $EnterpriseOwners -join ","
        
        # Finds the matching App Registration
        $MatchingAppRegistration = $AppRegistrations | Where-Object AppId -eq $enterpriseapp.AppId

        # Obtains the Owners value for the matching App Registation
        If($MatchingAppRegistration){
            $Owners = @(Get-MgApplicationOwner -ApplicationId $MatchingAppRegistration.ID)
            $AppOwners = $null
            $AppOwners = @(
                ForEach($owner in $owners){
                    Try{Get-MgUser -UserID $owner.id -erroraction Stop | Select-Object -ExpandProperty DisplayName} Catch {$EnterpriseApps | Where-Object Id -eq $owner.id | Select-Object -ExpandProperty DisplayName} # Apparently some can be owned by other enterprise apps, so I added this try/catch
                }
            )
            $AppOwnersCombined = $AppOwners -join ","
        } Else {$AppOwnersCombined = $null}

        # Builds the custom object to export to CSV
        [pscustomobject]@{
            EnterpriseApp = $enterpriseapp.DisplayName
            EnterpriseObjectID = $enterpriseapp.Id
            EnterpriseApplicationID = $enterpriseapp.AppId
            EnterpriseCreated = (Get-Date "$($enterpriseapp.additionalproperties.createdDateTime)").ToString("yyyy-MM-dd") # the createdDateTime key is CASE SENSITIVE specifically for the enterprise app
            EnterpriseCertificateExpiration = (($enterpriseapp | Select-Object -ExpandProperty keycredentials | Select-Object -ExpandProperty enddatetime | Sort-Object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)
            EnterprisePreferredSingleSignOnMode = $enterpriseapp.PreferredSingleSignOnMode
            EnterpriseServicePrincipalType = $enterpriseapp.serviceprincipaltype
            EnterpriseOwners = $EnterpriseOwnersCombined
            EnterpriseNotes = $enterpriseapp.notes
            AppRegistration = $MatchingAppRegistration.DisplayName
            AppObjectID = $MatchingAppRegistration.Id
            AppApplicationID = $MatchingAppRegistration.AppId
            AppCreated = If($MatchingAppRegistration.CreatedDateTime){(Get-Date "$($MatchingAppRegistration.CreatedDateTime)").ToString("yyyy-MM-dd")}Else{$null}
            AppClientSecretExpiration = (($MatchingAppRegistration | Select-Object -ExpandProperty passwordcredentials | Select-Object -ExpandProperty enddatetime | Sort-Object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)
            AppCertificateExpiration = (($MatchingAppRegistration | Select-Object -ExpandProperty keycredentials | Select-Object -ExpandProperty enddatetime |sort-object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)
            AppOwners = $AppOwnersCombined
            AppNotes = $MatchingAppRegistration.notes
        }
    }
)

# Adds any App Registrations that do not have a corresponding Enterprise Application to the array
$Results += @(
    ForEach($AppRegistration in $AppRegistrations){
        If(($enterpriseapps | Where-Object appid -eq $appRegistration.appid) -eq $null){

            # Obtains the Owners value for the App Registation
            $Owners = @(Try{Get-MgApplicationOwner -ApplicationId $appRegistration.appid -ErrorAction Stop}Catch{$null})
            $AppOwners = $null
            $AppOwners = @(
                ForEach($owner in $owners){
                    Try{Get-MgUser -UserID $owner.id -erroraction Stop | Select-Object -ExpandProperty DisplayName}Catch{$null}
                }
            )
            $AppOwnersCombined = $AppOwners -join ","
            
            # Builds the custom object to export to CSV
            [pscustomobject]@{
                EnterpriseApp = $null
                EnterpriseObjectID = $null
                EnterpriseApplicationID = $null
                EnterpriseCreated = $null
                EnterpriseCertificateExpiration = $null
                EnterprisePreferredSingleSignOnMode = $null
                EnterpriseServicePrincipalType = $null
                EnterpriseOwners = $null
                EnterpriseNotes = $null
                AppRegistration = $AppRegistration.DisplayName
                AppObjectID = $AppRegistration.Id
                AppCreated = (Get-Date "$($AppRegistration.CreatedDateTime)").ToString("yyyy-MM-dd")
                AppApplicationID = $AppRegistration.AppId
                AppClientSecretExpiration = (($appregistration | Select-Object -ExpandProperty passwordcredentials | Select-Object -ExpandProperty enddatetime | Sort-Object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)# -join "," 
                AppCertificateExpiration = (($appregistration | Select-Object -ExpandProperty keycredentials | Select-Object -ExpandProperty enddatetime |sort-object | Get-Unique) | ForEach-Object { [datetime]::Parse($_) } | Sort-Object -Descending | Select-Object -first 1)# -join ","
                AppOwners = $AppOwnersCombined
                AppNotes = $AppRegistration.Notes
            }
        }
    }
)

# Narrows the list down to just entries that have expirations in the next X number of days
$expiration = 45
$expiringsoon = $results | Where-Object EnterpriseServicePrincipalType -ne "ManagedIdentity" | Where-Object {($_.EnterpriseCertificateExpiration -ne $null -and $_.EnterpriseCertificateExpiration -lt (get-date).adddays($expiration)) -or ($_.AppClientSecretExpiration -ne $null -and $_.AppClientSecretExpiration -lt (get-date).adddays($expiration)) -or ($_.AppCertificateExpiration -ne $null -and $_.AppCertificateExpiration -lt (get-date).adddays($expiration))}

# Exports the results
$date = get-date -format yyyyMMddHHmmss
$Results | Export-Csv -path "$env:USERPROFILE\desktop\EntraApps_All_$date.csv" -NoTypeInformation -Force
$ExpiringSoon | Export-Csv -path "$env:USERPROFILE\desktop\EntraApps_ExpiringSoon_$date.csv" -NoTypeInformation -Force

One thought on “Find Expiring Enterprise Applications and App Registrations… With Microsoft Graph!

Leave a Reply

Your email address will not be published. Required fields are marked *