Automated Alerts on Azure (Entra ID) Application Secret Expirations
Table of Contents
Monitoring Azure AD (Entra ID now) application secret expirations in an enterprise is a critical aspect of maintaining robust security and ensuring uninterrupted service. When application secrets expire without timely renewal, it can disrupt business operations by causing application failures. Proactive management of application secret expirations helps enterprises avoid last-minute issues, enabling a more secure and efficient operational environment.
During my brief research in finding an automated approach to monitoring application secret expirations, I found multiple write-ups and articles but many only showed the code on how to get the expiration property without walking through setting up the automation itself. Another issue was not converting the default UTC time to local time to get more accurate expiration datetimes, and also dealing with applications with multiple secrets that expire at different times.
This article will walk one through the code’s logic, including converting time and dealing with multiple values, and creating multiple different automated alerting systems.
The following was performed using PowerShell Core
Create a New Azure (Entra ID) Application
First, we need to connect to Azure using Connect-AzAccount
Once connected, we need to create our own Azure or Entra Application that we will use to connect to the Microsoft Graph REST API in order to report on the other applications.
In my example below, I am creating a new application called EntraAppMonitor.
I am also getting the default tenant domain in order to create a generic Identifier URI.
$Domain = (Get-AzDomain).Domains | Where-Object { ($_ -like "*.onmicrosoft.com*") -and ($_ -notlike "*mail.*")} | Select-Object -First 1
$AppRegistrationSplat = @{
DisplayName = "EntraAppMonitor"
IdentifierURIs = "http://Entraappmonitor.$Domain"
ReplyUrls = "https://www.localhost/"
}
$AzureADApp = New-AzADApplication @AppRegistrationSplat
Going back to the Azure Portal, I can see my newly created Application.
Assign Permissions
Next, we need to give the application the permission, Application.Read.All
which is the following ID: 9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30
. Luckily, during the process of creating the application we stored the application information in the variable, $AzureADApp
so we can call the objectID
property of the application by using “$AzureADApp.ID
“.
Note: The AppID of 00000003-0000-0000-c000-000000000000 is the application ID for the Microsoft Graph.
Add-AzADAppPermission -ObjectId $AzureADApp.ID -ApiId '00000003-0000-0000-c000-000000000000' -PermissionId "9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30" -Type Role
Jumping back into the Azure Portal, we now need to grant admin consent, allowing the application to be granted to permission we assigned. I can see in the status pane that admin consent is required but has not been granted.
To grant admin consent, first click “Grant admin consent…” and then click “Yes“
Create an App Secret
Next, we need to create an application secret to we can connect to the Microsoft Graph API. The secret is similar to a password so it must be protected and secured. In the example below I will create a secret that will expire in one (1) year. Using the variable, “$AzureADApp
” that we created earlier, I can call the “AppID
” property to fill in our Application ID value.
[System.DateTime]$startDate = Get-Date
[System.DateTime]$endDate = $startDate.AddYears(1)
$AppSecret = Get-AzADApplication -ApplicationId $AzureADApp.AppID | New-AzADAppCredential -StartDate $startDate -EndDate $endDate
By calling “AppSecret” we can retrieve different values such as the Secret itself. Take note of the SecretText
value for later.
Note: You may have to call
$AppSecret.SecretText
to view the full secret text.
Build Our Monitor Logic
Connect to the Microsoft Graph API
The first thing we will want to do is build a PowerShell function to connect to the Microsoft Graph REST API using the application and secret that we created above.
Put in the Application ID of the application we made above, the Tenant ID of your Azure/Entra ID Tenant, and the Application Secret we created in the previous step.
Note: If you need to retrieve the application ID again, go to portal.azure.com > Microsoft Entra ID > App Registrations > [click you application]
$AppID = ''
$TenantID = ''
$AppSecret = ""
Function Connect-MSGraphAPI {
param (
[system.string]$AppID,
[system.string]$TenantID,
[system.string]$AppSecret
)
begin {
$URI = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
$ReqTokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $AppID
Client_Secret = $AppSecret
}
}
Process {
Write-Host "Connecting to the Graph API"
$Response = Invoke-RestMethod -Uri $URI -Method POST -Body $ReqTokenBody
}
End{
$Response
}
}
Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecre
TIP: If you are following along step by step, when running Connect-MSGraphAPI
, store the results in a variable for our other API calls.
$tokenResponse = Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecret
Get All Applications
Next, we need to recursively retrieve all applications. We can do this because we granted our application the Application.Read.All
permission so we just need to perform a GET
at the /applications/
endpoint.
For this, I created a single Function that performs a GET request on an endpoint, this makes it so I can re-use this function for different endpoints.
Function Get-MSGraphRequest {
param (
[system.string]$Uri,
[system.string]$AccessToken
)
begin {
$ReqTokenBody = @{
Headers = @{
"Content-Type" = "application/json"
"Authorization" = "Bearer $($AccessToken)"
}
Method = "Get"
Uri = $URI
}
}
process {
$Data = Invoke-RestMethod @ReqTokenBody
}
end {
$Data
}
}
Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"
Lets run ‘Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"
‘ and store the results in the variable $Applications
so we can view the properties easier.
$Applications = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"
Now I can run $Applications.value
and see my different applications and their associated properties.
Pagination
API pagination is a method implemented in API design and development for handling the retrieval of extensive data sets in an organized and efficient way. This technique is particularly useful when an API endpoint needs to deliver a substantial volume of data. By using pagination, the data is segmented into smaller, easier-to-manage portions, often referred to as pages. Each of these pages holds a specific, limited quantity of records or entries.
Some larger enterprises may have a large quantity of applications, so we need to ensure that when we get all applications, there are no other pages to parse. The way we do that, is if the results contain ‘@odata.nextLink
‘, grab the next pages URI and perform another GET method against that URI until there are no more pages.
In order to account for pagination, we will change our function to the code below:
Function Get-MSGraphRequest {
param (
[system.string]$Uri,
[system.string]$AccessToken
)
begin {
[System.Array]$allPages = @()
$ReqTokenBody = @{
Headers = @{
"Content-Type" = "application/json"
"Authorization" = "Bearer $($AccessToken)"
}
Method = "Get"
Uri = $Uri
}
}
process {
write-verbose "GET request at endpoint: $Uri"
$data = Invoke-RestMethod @ReqTokenBody
while ($data.'@odata.nextLink') {
$allPages += $data.value
$ReqTokenBody.Uri = $data.'@odata.nextLink'
$Data = Invoke-RestMethod @ReqTokenBody
# to avoid throttling, the loop will sleep for 3 seconds
Start-Sleep -Seconds 3
}
$allPages += $data.value
}
end {
Write-Verbose "Returning all results"
$allPages
}
}
$applications = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"
Get Application Secret Expiration
To view the application secret expiration, we can view the passwordcredentials
property.
If we wanted to view the application name along with the passwordexpiration details, we can run the following:
$Applications.value | select displayname, passwordcredentials | format-list
The passwordCredentials
results aren’t very human-friendly. This is because the property contains name-value pairs. passwordCredentials
is of the type NoteProperty
.
if we want to view the passwordCredentials
and the application displayName
we can run the following PowerShell code:
$Applications.value | select-object -ExpandProperty passwordcredentials -property @{name="ApplicationName"; expr={$_.displayName}}
Finding Expiring or Expired Secrets
By using New-Timespan
we can determine if a secret is expiring or has expired already. For the purposes of this article, a secret will be close to expiring if it’s going to expire in 30 days or under.
(New-TimeSpan -Start (Get-Date) -End ($_.passwordCredentials.endDateTime)).Days
There are two issues with the code below that I will dive into below:
endDateTime
is always UTC timeGet-Date
may not be your time zone. If you end up running this in a serverless runbook that is hosted in the Eastern Time Zone but you are located in the Central Time Zone,Get-Date
will display the eastern time.
Now, since we are dealing with days and not hours, this most likely wont be a problem. But depending on what you are monitoring or alerting on, this could be an issue, so for our article lets convert all DateTime
objects to our time zone.
Converting Time to Local Time Zone
As mentioned above, there are two DateTime
objects that we need to convert to our time zone. First, we need to run Get-Date
and ensure that it is getting the date for our time zone (central standard time for my case). Second, we need to convert endDateTime
from UTC to CST.
Get the Current DateTime from a Specific Time Zone
To get the current date and time for a specific time zone (in my case Central Standard Time) we can run the following:
([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")
Convert UTC to our Time Zone
Next, we need to convert endDateTime
from UTC to our Time Zone. To do this we can feed it the endDateTime
value.
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.passwordCredentials.endDateTime, 'Central Standard Time')
Dealing with Multiple Secrets per Application
When looking up similar articles, many just parse each application, get the secret expiration, and then see how many days until it expires. But some applications may contain multiple secrets, it’s not as common (think of a user/service account having multiple passwords) but I have seen it with different companies.
So, we must see if passwordCredentials.endDateTime
contains more than 1 value. Below is a snippet (will not work on its own) of that logic including the timezone conversions.
if ($_.passwordCredentials.endDateTime.count -gt 1) {
$endDates = $_.passwordCredentials.endDateTime
[int[]]$daysUntilExpiration = @()
foreach ($Date in $endDates) {
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($Date, 'Central Standard Time')
$daysUntilExpiration += (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
}
}
Bringing All The Application Expiration Logic Together
Remember earlier when we got all the application, we stored those results in a variable called Applications
. Now we can iterate through that array and output our application ID, Name and how many days until each secret is set to expire, while also converting everything to our local time zone.
$Applications.value | Sort-Object displayName | Foreach-Object {
#If there are more than one password credentials, we need to get the expiration of each one
if ($_.passwordCredentials.endDateTime.count -gt 1) {
$endDates = $_.passwordCredentials.endDateTime
[int[]]$daysUntilExpiration = @()
foreach ($Date in $endDates) {
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($Date, 'Central Standard Time')
$daysUntilExpiration += (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
}
}
Elseif ($_.passwordCredentials.endDateTime.count -eq 1) {
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.passwordCredentials.endDateTime, 'Central Standard Time')
$daysUntilExpiration = (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
}
$_ | Select-Object id, displayName, @{
name = "daysUntil";
expr = { $daysUntilExpiration }
}
}
Sending the Alert to Email
One of the available options of sending the alert is sending it via e-mail, which is the more traditional way. Since we are already interfacing with the Microsoft Graph REST API, we will be sending the e-mail through the API and then using Azure Serverless Automation (runbooks) to run on a set schedule.
Adding Send.Mail Permissions
First, we must grant our application the Send.Mail permission. For this I will be going through the Azure Portal instead of through PowerShell (only because we have already seen how to do it via PS).
One thing to note: to send email using the Microsoft Graph, you must send as a licensed user. In my case I will send it as myself ([email protected])
Go to portal.azure.com and then Microsoft Entra ID > App Registrations > and then click on your application that you created earlier and finally click “API Permissions” on the left pane.
Click + Add a Permission
Click Microsoft Graph > Application Permission and add Mail.Send
Once you have added the permissions, click Grant admin consent for... to apply the permissions.
Adding Send Email Logic
Next, we need to create a new PowerShell function that will send our email alert out. The below function will send an HTML email that contains a table of our application. When calling this function you will need to give it the URI (endpoint), AccessToken, To (who the email, or where the email goes to), and the Body (which the script will auto feed and format)
Note: The code below will not work on its own as we are not passing the accessToken, Body or URI yet. It relies on data from other functions. The code is shown to show you the logic behind sending the email.
Function Send-MSGraphEmail {
param (
[system.string]$Uri,
[system.string]$AccessToken,
[system.string]$To,
[system.string]$Subject = "App Secret Expiration Notice",
[system.string]$Body
)
begin {
$headers = @{
"Authorization" = "Bearer $($AccessToken)"
"Content-type" = "application/json"
}
$BodyJsonsend = @"
{
"message": {
"subject": "$Subject",
"body": {
"contentType": "HTML",
"content": "$($Body)"
},
"toRecipients": [
{
"emailAddress": {
"address": "$to"
}
}
]
},
"saveToSentItems": "true"
}
"@
}
process {
$data = Invoke-RestMethod -Method POST -Uri $Uri -Headers $headers -Body $BodyJsonsend
}
end {
$data
}
}
On the Outlook side here is what the email looks like:
Sending the Alert to Microsoft Teams
Next, I want to automate this to run daily and alert me in Microsoft Teams if any secrets are set to expire in thirty (30) days or less.
Luckily, I already have a Microsoft Team and channel that I send all of my enterprise alerting to (seen below)
Create a Channel Webhook
In order to send my alert to a Microsoft Teams Channel, I need to get the channels webhook information.
Go to the channel that you chose and navigate to Manage Channel > Connectors and then click Configure.
Give you Webhook a name and icon (optional) and then click Create
Notate the URI information and save it for later, we will need it in a later step.
Back in the channel chat you should see a message regarding your newly created webhook.
Send Alert to Teams
A new If
statement declares that if the daysUntil
value is less than, or equal to ’30’ to add the daysUntil
value, id
, and displayName
of the application in an array called array
.
Next, we need to convert the array object into HTML by using ConvertTo-HTML
. Lastly, I pasted in my webhook url to the $uri
variable.
$array = @()
$applications = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri "https://graph.microsoft.com/v1.0/applications/"
$Applications.value | Sort-Object displayName | Foreach-Object {
#If there are more than one password credentials, we need to get the expiration of each one
if ($_.passwordCredentials.endDateTime.count -gt 1) {
$endDates = $_.passwordCredentials.endDateTime
[int[]]$daysUntilExpiration = @()
foreach ($Date in $endDates) {
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($Date, 'Central Standard Time')
$daysUntilExpiration += (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
}
}
Elseif ($_.passwordCredentials.endDateTime.count -eq 1) {
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.passwordCredentials.endDateTime, 'Central Standard Time')
$daysUntilExpiration = (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, "Central Standard Time")) -End $Date).Days
}
if ($daysUntilExpiration -le 30) {
$array += $_ | Select-Object id, displayName, @{
name = "daysUntil";
expr = { $daysUntilExpiration }
}
}
}
$textTable = $array | Sort-Object daysUntil | select-object displayName, daysUntil | ConvertTo-Html
$JSONBody = [PSCustomObject][Ordered]@{
"@type" = "MessageCard"
"@context" = "<http://schema.org/extensions>"
"themeColor" = '0078D7'
"title" = "$($Array.count) App Secrets areExpiring Soon"
"text" = "$textTable"
}
$TeamMessageBody = ConvertTo-Json $JSONBody
$parameters = @{
"URI" = 'https://bwya77.webhook.office.com/webhookb2/eee030b9-93ef-4fae-add9-17bf369d1101@6438b2c9-54e9-4fce-9851-f00c24b5dc1f/IncomingWebhook/e322889da8ed47c4b3211f256f8ac57d/5bcffade-2afd-48a2-8096-390a9090555c'
"Method" = 'POST'
"Body" = $TeamMessageBody
"ContentType" = 'application/json'
}
Invoke-RestMethod @parameters
Jumping back to Teams I can see my newly created alert in my Teams Channel.
Automatic Serverless Automation
Create Automation Account and Runbook
Next step is to add this to a PowerShell runbook, this way I am not hardcoding secrets, and its running on a schedule.
Go to the Azure Portal > Automation Accounts and create a new one or use an existing one.
Next, go to your automation account and click Variables. I will be adding three variables:
- tenantID
- appSecret
- appID
Make sure you click Yes for encryption.
Change the PowerShell Script to get the newly created automation variables.
$AppID = Get-AutomationVariable -Name 'appID'
$TenantID = Get-AutomationVariable -Name 'tenantID'
$AppSecret = Get-AutomationVariable -Name 'appSecret'
Next, I will create a new runbook
Next, I will paste the code from below to my newly created runbook. Make sure you have your webhook url to the variable teamswebhookURI
. (this is only applicable if you are doing the Microsoft Teams alerting)
When finished, click Publish.
Create Schedule
We now need to create a schedule so this runbook can run on a regular cadence. In the runbook click Schedules and then create a schedule that best fits your needs. In my example I have it running daily at 8:00 AM.
Obtain the Source Code
The scripts are hosted on my GitHub . Feel free to send in issues and contribute to the project as well.
My name is Bradley Wyatt; I am a 4x Microsoft Most Valuable Professional in Cloud and Datacenter Management. I have given talks at many different conferences, user groups, and companies throughout the United States ranging from PowerShell to DevOps Security best practices and am the 2022 North American Outstanding Contribution to the Microsoft Community winner.
40 thoughts on “Automated Alerts on Azure (Entra ID) Application Secret Expirations”
Your article is truly inspiring and informative. It’s commendable of you to share your knowledge with others and contribute to their growth. I’m grateful for the valuable insights you’ve provided and look forward to implementing them in my own endeavours. Thank you for being such a valuable resource.
Great detailed solution, I’m planning something similar in my environment and this will help me get started. I prefer automation runbooks over logic apps which is what I’ve seen similar efforts use. What are your thoughts on using the automation account identity for access instead of creating the app registration?
I dont know of any downsides to using a Managed Identity – I am just used to going straight for the enterprise app
I couldnt get the Get-MSGraphRequest method to work, the Runbook kept failing. I had to change it to:
# Function to make HTTP request to Microsoft Graph API
function Invoke-GraphApiRequest {
param (
[string]$Uri,
[string]$AccessToken
)
$headers = @{
‘Authorization’ = “Bearer $AccessToken”
‘Content-Type’ = ‘application/json’
}
return Invoke-RestMethod -Uri $Uri -Method GET -Headers $headers
}
# Get applications using Microsoft Graph API
$applicationsUri = “https://graph.microsoft.com/v1.0/applications/”
$applications = Invoke-GraphApiRequest -Uri $applicationsUri -AccessToken $tokenResponse.access_token
# Process each application
$applications.value | Sort-Object displayName | Foreach-Object {
$expirationDates = $_.passwordCredentials.endDateTime
}
I had updated the entire runbook last night which mightve fixed whatever you were running into. (I was calling the incorrect function to get the encrypted vars)
Hi @Brad
There is still a problem with function ,,Get-MSGraphRequest”. I tried using your script using local versions of PowerShell (version PS 5.1 & 7.1) in my VM. TThen I tried to use the automatic account, unfortunately to no avail. Locally, the problem is that the function does not work properly and does not collect application data. However, in Automate Account there is a problem with “Exception of type ‘System.OutOfMemoryException’ was thrown”.
The script will be very helpful, so I care about its correct operation.
I was handling pagination incorrectly, the new updated scripts on my github address this
Great post, really clear guide. Can you advise of the upside of doing this (and other similar processes) using an Azure Runbook rather than a Logic App? I already have something like this setup using a Logic App and Managed Identity. Curious to see if I’m better switching this to a runbook and using this method in the future.
Thanks
If you aren’t having issues with the Logic App I’d probably suggest staying with it.
Depending how often you are running you Logic App(s), I could see the logic app incurring more cost than this automation as it may take slightly longer to run to completion and Logic Apps charges on Memory, vCPU, and connectors
Script is hangs on $applications. When i don’t add -Scopes “Application.Read.All” in $tokenResponse then is hanging on $tokenResponse. Any idea?
I was handling pagination incorrectly, the new updated scripts on my github address this
Thanks for script update. Now i have a problem with time convert. I using “Central European Standard Time” and powershell showing me two types of errors:
Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”. $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.pass …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodException
+ FullyQualifiedErrorId : MethodCountCouldNotFindBest
Second:
New-TimeSpan : Cannot bind parameter ‘End’ to the target. Exception setting “End”: “Cannot convert null to type “System.DateTime”.” … ([DateTime]::Now, “Central European Standard Time”)) -End $Date).Days
+ ~~~~~
+ CategoryInfo : WriteError: (:) [New-TimeSpan], ParameterBindingException
+ FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.PowerShell.Commands.NewTimeSpanCommand
Can you help me also in this one?
@Brad any help?
Worked with Mateusz on this and its been resolved – updated code is on GitHub
Can this be modified to send the alert email to the owner of the SP instead of sending it to a specific email ID?
Hi,
It seems that when running the script using the registered app and secret, only returns a part of the applications.
When i run the same code authenticating with my own user, i get far more applications.
Any idea why this is?
The app has the Application.Read.All permission.
I had an error with pagination, its been resolved and the code is on GitHub
Great script. Is it possible to only return in mail secrets that are below the days set? Now it also returns (some randomly?) secrets that are already expired.
Nice article, good work.
You know what would be really useful – a property exposed on an app registration for “token last issued” or “last activity date”. You can have some app registrations that aren’t actively being used, and that’s often a question you have to track down – “is this still being used, does it need updating”. Some way of easily understanding your active app registrations, and therefore able to easily understand the impact of an outage would help prioritise this stuff. Feature request to MS I think
Hi Brad! Thank you so much for this tutorials! very helpful for my new project! I did the comparison between the code from your github and the code on your website, are those two supposed to be the same or different? Thanks!
Github has the most recent code. It contains bug fixes and improvements since I wrote the blog post
Hi Brad, how do we remove the one that says -300 or -123 from the result?
Hi Brad can you please post the updated Repo for this
Hi Brad, we getting this error now for some reason when running the script…
Connecting to the Graph API
Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.
At line:116 char:13
+ $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_. …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodException
+ FullyQualifiedErrorId : MethodCountCouldNotFindBest
Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.
At line:116 char:13
+ $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_. …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodException
+ FullyQualifiedErrorId : MethodCountCouldNotFindBest
Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.
At line:116 char:13
+ $Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_. …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodException
+ FullyQualifiedErrorId : MethodCountCouldNotFindBest
Running into an issue with this:
(New-TimeSpan -Start (Get-Date) -End ($_.passwordCredentials.endDateTime)).Days
New-TimeSpan : Cannot bind parameter ‘End’ to the target. Exception setting “End”: “Cannot convert null to type
“System.DateTime”.”
At line:1 char:39
+ … an -Start (Get-Date) -End ($_.passwordCredentials.endDateTime)).Days
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (:) [New-TimeSpan], ParameterBindingException
+ FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.PowerShell.Commands.NewTimeSpanCommand
Thank you for this tutorial.
Here is the “t” missing:
Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecre
When I set “(New-TimeSpan -Start (Get-Date) -End ($_.passwordCredentials.endDateTime)).Days” I get following error:
New-TimeSpan: The End parameter cannot be bound to the target. Exception when setting End: “NULL cannot be in type System.DateTime”
be converted.
Excellent Article, But when I tried facing the below error message:Invoke-RestMethod: Line | 52 | $data = Invoke-RestMethod @ReqTokenBody | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | {“error”:{“code”:”Authorization_RequestDenied”,”message”:”Insufficient privileges to complete the operation.”,”innerError”:{“date”:”2024-05-08T10:21:02″,”request-id”:”c–“,”client-request-id”:”—-“}}}
Could you kindly guide me how to fix this
Hi,
had the same problem with the New-Timespan.
Did some troubleshooting, and found that it’s actually to do with the FOREACH cycle’s logic.
Two of them are actually not needed, so one of them provides empty “.endDateTime” value.
Did some changes there and used only one foreach element – and it works fine now.
Great solution BTW!
For those having issues with error:
Multiple ambiguous overloads found for “ConvertTimeBySystemTimeZoneId” and the argument count: “2”.
I found that if I cast the endDateTime value to [DateTime] it fixed it for me:
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]$_.endDateTime, ‘Central Standard Time’)
Hi Brad,
Thank you so much for this tutorials! very helpful.
Can we also monitor certificate expiration ?
Thank you
Ganesh
This was awesome! I found the formatting got messed up in sending the email to the help desk. Sent as an attachment instead and added the app id.
Function Send-MSGraphEmail {
param (
[system.string]$Uri,
[system.string]$AccessToken,
[system.string]$To,
[system.string]$Subject = “App Secret Expiration Notice”,
[system.string]$Body,
[system.string]$AttachmentPath
)
begin {
$headers = @{
“Authorization” = “Bearer $($AccessToken)”
“Content-type” = “application/json”
}
$BodyJsonsend = @”
{
“message”: {
“subject”: “$Subject”,
“body”: {
“contentType”: “HTML”,
“content”: “$($Body)”
},
“toRecipients”: [
{
“emailAddress”: {
“address”: “$to”
}
}
],
“attachments”: [
{
“@odata.type”: “#microsoft.graph.fileAttachment”,
“name”: “$(Split-Path $AttachmentPath -Leaf)”,
“contentBytes”: “$(Get-Content -Path $AttachmentPath -Raw | ForEach-Object { [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($_)) })”
}
]
},
“saveToSentItems”: “true”
}
“@
}
process {
$data = Invoke-RestMethod -Method POST -Uri $Uri -Headers $headers -Body $BodyJsonsend
}
end {
$data
}
}
$tokenResponse = Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecret
$array = @()
$apps = Get-MSGraphRequest -AccessToken $tokenResponse.access_token -Uri “https://graph.microsoft.com/v1.0/applications/”
foreach ($app in $apps) {
$app.passwordCredentials | foreach-object {
#If there is a secret with an enddatetime, we need to get the expiration of each one
if ($_.endDateTime -ne $null) {
[system.string]$secretdisplayName = $_.displayName
[system.string]$id = $app.id
[system.string]$displayname = $app.displayName
$Date = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($_.endDateTime, ‘Central Standard Time’)
[int32]$daysUntilExpiration = (New-TimeSpan -Start ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTime]::Now, “Central Standard Time”)) -End $Date).Days
if (($daysUntilExpiration -ne $null) -and ($daysUntilExpiration -le $expirationDays)) {
$array += $_ | Select-Object @{
name = “id”;
expr = { $id }
},
@{
name = “displayName”;
expr = { $displayName }
},
@{
name = “secretName”;
expr = { $secretdisplayName }
},
@{
name = “daysUntil”;
expr = { $daysUntilExpiration }
}
}
$daysUntilExpiration = $null
$secretdisplayName = $null
}
}
}
if ($array.Count -gt 0) {
# Export expiring secrets to a CSV file
$array | Export-Csv -Path “C:ExpiringSecrets.csv” -NoTypeInformation
# Send email with CSV attachment
$emailSubject = “App Secret Expiration Notice”
$emailBody = “Please find the expiring secrets in the attached CSV file.”
# Construct email parameters
$emailParams = @{
Uri = “https://graph.microsoft.com/v1.0/users/$emailSender/sendMail”
AccessToken = $tokenResponse.access_token
To = $emailTo
Subject = $emailSubject
Body = $emailBody
AttachmentPath = “ExpiringSecrets.csv”
}
# Send email with attachment
Send-MSGraphEmail @emailParams
}
else {
Write-Output “No apps with expiring secrets”
}
Just a minor typo, but there’s a “t” missing and the end of this line :
“Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecre” should read “Connect-MSGraphAPI -AppID $AppID -TenantID $TenantID -AppSecret $AppSecret”
😉
With the new update from microsoft to retire O365 connectors, you will no longer be able to use the webhooks in teams above but instead you would need to modify the script above just a little so you can use the power platform workflows. The edited part is below for anyone who might be having this issue:
if ($array.count -ne 0) {
Write-output “Sending Teams Message”
$textTable = $array | Sort-Object daysUntil | select-object displayName, secretName, daysUntil | ConvertTo-Html
$JSONBody = @{
type = “message”
attachments = @(
@{
contentType = “$textTable”
content = @{
“$schema” = “http://adaptivecards.io/schemas/adaptive-card.json”
type = “AdaptiveCard”
version = “1.2”
}
}
)
}
This allows you to send a POST to the webhook for the target teams channel.
Hey Toyin,
I’m getting an error on invoke-restmethod with your fix. Are you sure you haven’t done any other changes as well?
I removed the $JSONBody from the original script, but kept the following:
$TeamMessageBody = ConvertTo-Json $JSONBody
$parameters = @{
“URI” = $teamsWebhookURI
“Method” = ‘POST’
“Body” = $TeamMessageBody
“ContentType” = ‘application/json’
}
Getting the following error:
Cannot convert ‘System.Object[]’ to the type ‘System.String’ required by parameter ‘ContentType’. Specified method is not supported.
Hey Toyin, By any chance do you have the complete setup with details for the new alternate?
TIA
Hi Toyin, It was very useful post to get the events from enterprise application . My question is can we set up the altering to SLACK Channel , if yes how we can do that .
teams notification alerts is not going to work. Because the webhook connector is soon going to deprecate. Msft is suggesting using power-automate workflows to replace them.
How can we do with power-automate workflows?
I want to follow this but there are so many things that doesn’t make sense to someone who is not well-versed in Powershell or comfortable with the Cloud shell. I am running into errors immediately that I don’t know how to resolve. But thanks anyway, inspiring content!