Pablo Cibraro

My notes about software development, security and other stuff.

Check secret expiration for Azure AD Applications using Powershell

When you assign a client ID/secret to an Application Registration in Azure AD, the expiration for that secret is one of the input settings. It could expire in one or two years, or never.

Bad news is that Microsoft does not provide any support out of the box to notify sys admins when a secret is near to expire, so you have to build that feature for yourself unless no expiration is selected, which is never recommended.

You can still use the Graph API to query the applications, and check the expiration date for each secret. The following script does exactly that with Powershell.

param(

    [string]$TenantId,
    [string]$Thumbprint,
    [string]$AppId,
    [bool]$RunAsJob = $false,
    [int]$Days = 30,
    [string]$SmtpUser,
    [string]$SmtpPassword
)

$ErrorActionPreference = "Stop"

$today = (Get-Date).ToUniversalTime()

$limitDate = $today.AddDays($Days)

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

function GenerateJWT (){

    $cert = Get-Item Cert:\LocalMachine\My\$Thumbprint

    $hash = $cert.GetCertHash()
    $hashValue = [System.Convert]::ToBase64String($hash) -replace '\+','-' -replace '/','_' -replace '='

    $exp = ([DateTimeOffset](Get-Date).AddHours(1).ToUniversalTime()).ToUnixTimeSeconds()
    $nbf = ([DateTimeOffset](Get-Date).ToUniversalTime()).ToUnixTimeSeconds()

    $jti = New-Guid

    [hashtable]$header = @{alg = "RS256"; typ = "JWT"; x5t=$hashValue}
    [hashtable]$payload = @{aud = "https://login.microsoftonline.com/$TenantId/oauth2/token"; iss = $AppId; sub=$AppId; jti = $jti; exp = $Exp; Nbf= $Nbf}

    $headerjson = $header | ConvertTo-Json -Compress
    $payloadjson = $payload | ConvertTo-Json -Compress


    $headerjsonbase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($headerjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')
    $payloadjsonbase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($payloadjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')

    $toSign = [System.Text.Encoding]::UTF8.GetBytes($headerjsonbase64 + "." + $payloadjsonbase64)

    $rsa = $cert.PrivateKey -as [System.Security.Cryptography.RSACryptoServiceProvider]

    $signature = [Convert]::ToBase64String($rsa.SignData($toSign,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)) -replace '\+','-' -replace '/','_' -replace '='

    $token = "$headerjsonbase64.$payloadjsonbase64.$signature"

    return $token
}

if($RunAsJob)
{
    $RequestToken = GenerateJWT

    $AccessTokenResponse = Invoke-WebRequest -Method POST -ContentType "application/x-www-form-urlencoded" -Headers @{"accept"="application/json"} -Body "scope=https://graph.microsoft.com/.default&client_id=$AppId&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=$RequestToken&grant_type=client_credentials" -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

    $AccessTokenJsonResponse = ConvertFrom-Json $AccessTokenResponse.Content

    $AccessToken = $AccessTokenJsonResponse.access_token
}
else {
    if (!(Get-Module "MSAL.PS")) {
        Import-Module "MSAL.PS"
    }

    $TokenResponse = Get-MsalToken -ClientId $AppId `
     -TenantId $TenantId `
     -Interactive `
     -Scopes 'https://graph.microsoft.com/Application.Read.All'

    $AccessToken = $TokenResponse.AccessToken
}

$GraphApiUrl="https://graph.microsoft.com/v1.0/applications";

$headers = @{}

$headers.Add("Accept","application/json")

$headers.Add("content-type","application/json")

$headers.Add("Authorization","bearer $AccessToken")

$GraphApiResponse=Invoke-RestMethod -Uri $GraphApiUrl -Headers $headers -Method GET

$Apps = @()
$Apps+=$GraphApiResponse.value

while($GraphApiResponse.'@odata.nextLink' -ne $null) {
    $GraphApiResponse = Invoke-RestMethod -Uri $GraphApiResponse.'@odata.nextLink' -Headers $headers -Method Get

    $Apps+=$GraphApiResponse.value
}

$ExpiredApps = @()

foreach($App in $Apps) 
{

    if($App.passwordCredentials -ne $null)
    {
        foreach($Credential in $App.passwordCredentials) 
        {
            if($Credential.endDateTime) {
                $credentialEndDate = [datetime]::Parse($Credential.endDateTime) 
                if($credentialEndDate -le $limitDate -OR $credentialEndDate -lt $today)
                {
                    $ExpiredApps += @{
                        DisplayName = $App.DisplayName
                        Id = $App.Id
                        AppId = $App.AppId
                        Expiration = ($Credential.endDateTime) 
                        KeyId = $Credential.KeyId
                    }
                }
            }
        }
    }
}

if($ExpiredApps.Count -EQ 0) {
    Write-Output "No Apps found"
}
else {
    Write-Output "Apps that will expire soon"
    Write-Output $ExpiredApps.Count
    Write-Output $ExpiredApps
}

You can use that script in any scheduled job and be notified via email about all the client secrets that are near to expire.