Azure App Registrations are essential components of modern application architecture, facilitating secure communication between apps and services. However, the keys associated with these registrations – be it client secrets or key certificates – come with an expiration date. Failing to renew these keys in a timely manner can lead to service disruptions and security vulnerabilities.
Traditional approaches via Graph API and Power Automate, often involve time-consuming nested loops and iterations through key arrays, leading to complexity and potential performance bottlenecks. As api limits on the Platform become enforced, I have built a super efficient flow to demonstrate the alternative.
Even if the implementation of client secret expiry notifications isn’t on your immediate agenda, grasping these concepts for data manipulation within Power Automate remains crucial for optimizing your workflow efficiency.
My solution addresses these challenges by combining the capabilities of Power Automate and the Graph API in a way that sets it apart from the rest. The primary goal is to identify and monitor app registrations with keys set to expire within the next 30 days and proactively notify their owners.
- Efficiency Through XPath: One of the key differentiators of my solution is the use of XPath, a powerful language for querying XML-like structures. By harnessing XPath, I eliminate the need for nested “apply to each” actions when dealing with nested key arrays within app registration objects. This significantly boosts the efficiency of the process, reducing unnecessary iterations and minimizing processing overhead.
- Holistic Expiry Analysis: My approach doesn’t merely focus on detecting key expirations; it takes a holistic approach by identifying not only the keys but also the app owners. This added layer of insight empowers administrators to not only react to expiring keys but also proactively reach out to the respective application owners, streamlining the renewal process.
- Automation Possibilities: By combining key expiry data with owner information, my solution sets the stage for automation. Administrators can potentially automate the process of notifying application owners about upcoming expirations, thus fostering a culture of proactive maintenance and reducing the risk of service interruptions.
The solution
- In Azure:
Create Azure AD App Registration: Set up an Azure AD App Registration with delegated permissions: Application.Read.All, Application.ReadWrite.All, and Directory.Read.All. Generate and note down a client secret and client ID to obtain a bearer token in Power Automate.
- In Power Automate:
Obtain a bearer token: using the OAuth 2.0 token endpoint from your app registration, obtain a bearer token for your HTTP API calls to the applications Graph API endpoint. - Query Applications Endpoint: Utilize the Graph API by querying the applications endpoint (https://graph.microsoft.com/v1.0/applications) in manageable pages of 999 or fewer. Utilize a “do until” loop for up to 60 pages (extendable to 5000). Consider an “apply to each” approach if your app reg limits require it.
- Append Pages to Array: Store the retrieved data pages in an array to consolidate the information.
- Convert JSON to XML: Transform the JSON array into XML format for easier parsing and manipulation.
- Obtain Key IDs: Extract the key IDs for both password credentials and key credentials from the XML data.
- Query XML for Details: Leverage XML and the select action to retrieve further app registration details and specific key ID information. Avoid nested “apply to each” by using this method.
- Add Date Difference Field: Enhance the obtained data by calculating and appending a date difference field, enabling efficient filtering.
- Filter Expiry Range: Filter the dataset based on the date difference field, focusing on keys expiring within the range of -7 to 30 days.
- Call Graph API for Owners: Utilize the Graph API’s application owners endpoint to retrieve owners for each app registration. Use an “apply to each” loop for individual API calls per application.
- Final JSON Array: Your resulting JSON array will contain objects with details such as Key ID, display name, start and end date-times, app ID, app display name, type of key, and owner email.
The Graph API Applications Response Body
Below is a sample application object that would be returned as part of the response body. Note that the passwordCredentials array contains multiple objects. The keyCredentials could also contain multiple objects.
{ "id": "2809fa0d-ca66-46dd-b6da-8849141537c8", "appId": "beb696c2-a1d9-471b-995f-12721e7b8bed", "displayName": "MyFirstBotDBird", "passwordCredentials": [ { "customKeyIdentifier": null, "displayName": "What's the secret?", "endDateTime": "2023-09-18T23:00:00Z", "hint": "zha", "keyId": "1a463527-53c6-4485-9587-79290af3ccfd", "secretText": null, "startDateTime": "2023-08-31T11:06:18.048Z" }, { "customKeyIdentifier": null, "displayName": null, "endDateTime": "2025-01-15T00:00:00Z", "hint": "m7F", "keyId": "04b1f9c5-1c5f-4cb2-9ff9-af73275b67ed", "secretText": null, "startDateTime": "2020-01-15T11:11:25.5631325Z" }, { "customKeyIdentifier": null, "displayName": "Password uploaded on Wed Jan 15 2020", "endDateTime": "2021-01-15T11:52:21.307Z", "hint": "5xG", "keyId": "9b17b698-da10-44d7-84ed-f4649b5be5eb", "secretText": null, "startDateTime": "2020-01-15T11:52:31.972Z" } ], "keyCredentials": [] }
In order to traverse this data using traditional methods, you would need to loop through the applications array to return all of the application objects (as per the example above). You would then need to loop through both the passwordCredentials and keyCredentials array. This would create nested apply to each actions and a large number of API calls, many variables and a complex flow. Below I will show you how to avoid the apply to each on the data manipulation, once the data has been obtained from the endpoint.
The Flow
The flow has 13 actions, 2 of which are loops and contain a few more actions. A do until to get the pages of the app registrations and an apply to each to get the owners of each app registration.
Actions 1-3 (Obtain a bearer token and setup variables):
In HTTP, obtain a bearer token from https://login.microsoftonline.com/{tenant id}/oauth2/v2.0/token with a body to include your app client id and secret
grant_type=client_credentials
&client_id=ab12cd34-5678-90ef-ghij-klmnopqrstuv
&client_secret=MyFakeClientSecret123!
&scope=https://graph.microsoft.com/.default
Initialize a URI variable for the applications endpoint https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,passwordCredentials,keyCredentials&$top=999 and an empty array for our responses [].
Action 4: contains 4 further actions (Query Applications Endpoint and Append Pages to Array)
The do until is based on the URI variable being equal to empty. We set it to the applications endpoint on initialise, but then update it to body(‘HTTP_GetApps’)?[‘@odata.nextLink’] via the set variable action. If there are no further pages of data, the nextLink will be empty.
The HTTP GetApps is calling the URI variable, with our bearer token body(‘HTTP’)?[‘access_token’] from the earlier HTTP call.
The ComposeResponseVar is a trick, to prevent a variable self referencing itself. We want to create a union of each page of results. A variable cannot self reference, therefore we use a compose to create a copy of the existing variable content and in the set variable reponse, we combine the output of the compose with that of the http call and create an almalgated array union(outputs(‘ComposeResponseVar’),body(‘HTTP_GetApps’)?[‘value’]).
Actions 5-6 (Convert JSON to XML)
Here we create a json object with root and key name for the variable array of applications. With this, we convert the JSON to XML xml(outputs(‘Compose_Root’))
Actions 7-8 (Obtain Key IDs and Generate XML Queries)
These two actions are a select and almost the identical. One for the passwordCredentials object and the other for the keyCredentials object returned by the HTTP call to the application endpoint.
The from is an xml query to obtain the keyid’s for the password or key objects xpath(outputs(‘ComposeXML’), ‘//passwordCredentials/keyId/text()’) or xpath(outputs(‘ComposeXML’), ‘//keyCredentials/keyId/text()’).
Then we are obtaining the keyid based on item() as the array created from the above expression is an array of keyid’s. Feel free to place these queries into a seperate compose to see what the output is. The advantage of XML at this point is the keyid is part of nested arrays and normal methods would involve nested apply to each actions.
What you see after this is the xml query for each of the properties we want to obtain from the XML. The Select Key XPath is identical, with the exception of the query changing all instances of passwordCredentials with keyCredentials and setting type from ClientSecret or Certificate. This creates us two arrays of the keyid’s and the xml to obtain the additional data about the key, the dates, displaynames etc.
{
“KeyID”: @{item()},
“KeydisplayName”: “//passwordCredentials[keyId=’@{item()}’]/displayName/text()”,
“startDateTime”: “//passwordCredentials[keyId=’@{item()}’]/startDateTime/text()”,
“endDateTime”: “//passwordCredentials[keyId=’@{item()}’]/endDateTime/text()”,
“appId”: “//apps[passwordCredentials/keyId=’@{item()}’]/appId/text()”,
“appdisplayName”: “//apps[passwordCredentials/keyId=’@{item()}’]/displayName/text()”,
“id”: “//apps[passwordCredentials/keyId=’@{item()}’]/id/text()”,
“Type”: “ClientSecret”
}
{
“KeyID”: @{item()},
“KeydisplayName”: “//keyCredentials[keyId=’@{item()}’]/displayName/text()”,
“startDateTime”: “//keyCredentials[keyId=’@{item()}’]/startDateTime/text()”,
“endDateTime”: “//keyCredentials[keyId=’@{item()}’]/endDateTime/text()”,
“appId”: “//apps[keyCredentials/keyId=’@{item()}’]/appId/text()”,
“appdisplayName”: “//apps[keyCredentials/keyId=’@{item()}’]/displayName/text()”,
“id”: “//apps[keyCredentials/keyId=’@{item()}’]/id/text()”,
“Type”: “Certificate”
}
Action 9 (Query XML for Details)
The input is a union of both arrays from above so that we can obtain all of the data in one array union(body(‘Select_Pwd_XPath’),body(‘Select_Key_XPath’)). The KeyID is item()?[‘keyid’], Type is item()?[‘type’], followed by expressions to query the xml using the epxressions created in the above arrays
first(xpath(outputs(‘ComposeXML’),item()?[‘keydisplayName’]))
first(xpath(outputs(‘ComposeXML’),item()?[‘startDateTime’]))
first(xpath(outputs(‘ComposeXML’),item()?[‘endDateTime’]))
first(xpath(outputs(‘ComposeXML’),item()?[‘Name of key in above Selects etc’]))
This leaves us with a JSON array of all of the fields we require – or does it?
Actions 10-11 (Add Date Difference Field)
Here we add a new property to our array of data, the date difference as a key called datediff. addproperty(item(),’datediff’,int(split(split(dateDifference(utcnow(‘yyyy-MM-dd’),formatdatetime(item()?[‘enddatetime’],’yyyy-MM-dd’)),’.’)?[0],’:’)?[0]))
dateDifference dateDifference(‘2020-02-08’, ‘20123-07-30’) will return a date time string for example “1268.00:00:00”. We have formatted both today utcnow() and the enddatetime to ISO8601 yyyy-MM-dd. Then we have obtained the date difference and split the string on . to create an array [“1268″,”00:00:00”] and obtain the first value [0]. But we then split on :. The reason for this is if the dates being compared are identical, the string returned is “00:00:00” which cannot split on . and cannot be converted to int. The first value of the array [“00″,”00″,”00”] can be converted to int 0.
The 2nd action is filtering on the output of our select with the date difference, where the datediff value is less than or equal to 30 days and greater than or equal to -7. I.e. close to or recently expired. @and(lessOrEquals(item()?[‘datediff’], 30),greaterOrEquals(item()?[‘datediff’], -7))
Actions 12-13: with the apply to each containing a further 2 actions (Call Graph API for Owners and Final JSON Array)
We’ve got an apply to each where the input is the body from our filtered array of apps due to expire soon and the aim is to obtain the owners of these apps (if they exist). The HTTP call https://graph.microsoft.com/v1.0/applications/@{items(‘Apply_to_each’)?[‘Id’]}/owners required the App ID and by return we have obtained the first email address of the owner and created a new property on the object of each loop in a compose action addProperty(items(‘Apply_to_each’),’Owner’,body(‘HTTP_GetOwners’)?[‘value’]?[0]?[‘mail’]).
Outside of the apply to each, we have a compose that takes advantage of the fact that we can combine data from within a compose inside an apply to each, to create ourselves an array of all of the new data to include the app owner outputs(‘composeowners’). The name inside the quotes, must match the name of the compose in the apply to each.
With the output from the compose, we now have a concise array of data.
{ "KeyID": "1a463527-53c6-4485-9587-79290af3ccfd", "KeydisplayName": "What's the secret?", "startDateTime": "2023-08-31T11:06:18.048Z", "endDateTime": "2023-09-18T23:00:00Z", "appId": "beb696c2-a1d9-471b-995f-12721e7b8bed", "appdisplayName": "MyFirstBotDBird", "Id": "2809fa0d-ca66-46dd-b6da-8849141537c8", "Type": "ClientSecret", "datediff": 18, "Owner": "damien@abdndamodev.onmicrosoft.com" }
We can summarize as an html table, send a request to app owners, and more. I would love to know in the comments if you have made use of the solution above and where you took it to next.
One more efficient way to handle the expiring secrets for AppReg https://aammir-mirza.medium.com/azure-service-principal-expiry-notification-b907952e55d3
are you able to post an ARM template where we can fill in our own details?
Hi Jay, I must admit I am not familiar with ARM templates as my focus is low code. I’ll take a read one day but it’s not something I can support right now.
I’m having an issue with action 9 – either the expression is different in logic apps or I’ve mistyped something somewhere. Testing the logic app returns this error:
InvalidTemplate. The execution of template action ‘SelectPwdsKeysFromAppsWithDates’ failed: The evaluation of ‘query’ action ‘where’ expression ‘{ “Id”: “@first(xpath(outputs(‘ComposeXML’),item()?[‘id’]))”, “KeyID”: “@item()?[‘keyId’]”, “KeydisplayName”: “@first(xpath(outputs(‘ComposeXML’),item()?[‘keydisplayName’]))”, “Type”: “@item()?[‘Type’]”, “appdisplayName”: “@first(xpath(outputs(‘ComposeXML’),item()?[‘displayName’]))”, “appid”: “@first(xpath(outputs(‘ComposeXML’),item()?[‘appId’]))”, “endDateTime”: “@first(xpath(outputs(‘ComposeXML’),item()?[‘endDateTime’]))”, “startDateTime”: “@first(xpath(outputs(‘ComposeXML’),item()?[‘startDateTime’]))” }’ failed: ‘The template language function ‘xpath’ parameters are invalid: the ‘xpath’ parameter must be a supported, well formed XPath expression. Please see https://aka.ms/logicexpressions#xpath for usage details.’.
Start by looking at the two Select actions where you are building the XPath queries. Probably something like a typo in one or the other action. Can try and narrow it down by changing the Union() expression in SelectPwdKeysAppsWithDates to just use the body from Select_Pwd_Xpath or Select_Key_Xpath
I am having the same issue
I’m having the following erro 4{“error”:”invalid_client”,”error_description”:”AADSTS7000215: Invalid client secret provided
Hi David,
Step 9 doesn’t seem to work. What should be the correct expression? I tried with only one
first(xpath(outputs(‘ComposeXML’),item()?[‘keydisplayName’]))
and it still doesn’t work.
ERROR
The execution of template action ‘SelectPwdKeysFromAppsWithDates’ failed: The evaluation of ‘query’ action ‘where’ expression ‘{ “KeyID”: “@{item()?[‘keyid’]}”, “Type”: “@{item()?[‘type’]}”, “id”: “@{first(xpath(outputs(‘ComposeXML’),item()?[‘keydisplayName’]))}” }’ failed: ‘The template language function ‘xpath’ parameters are invalid: the ‘xpath’ parameter must be a supported, well formed XPath expression. Please see https://aka.ms/logicexpressions#xpath for usage details.’.
Everyone seems to be stack in the same step… 😁 I am testing another solution using Logic Apps
Can you check something for me? Have your quotes copied into the expression as 66 99 instead of “? Thanks
The likely causes are either that one of the expressions item()?[‘keydisplayname’] returns null because of a typo or the action before, that creates the array of xml queries is doing so incorrectly. Xpath is complaining about an invalid xml expression.
You can’t copy and paste directly into the mappings without it throwing a JSON formatting error. You have to copy into notepad to fix indentation and replace the quotes first.
I’ve gone through all steps multiple times looking for any typos, still getting invalid xml errors
Hi there
thanks for making this detailed post
one thing, has something changed with the way the token authenticates?
I am getting insufficient permissions but have the 3 delegated permissions you have listed.
I’m getting the token but in an analyzer it doesnt show any roles.
Kinda lost at what im doing wrong.
For those having issues on step 9, I found a couple of issues. One being capitalization. For the KeydisplayName the expression in the blog shows first(xpath(outputs(‘ComposeXML’),item()?[‘keydisplayName’])) when I found this to work first(xpath(outputs(‘ComposeXML’),item()?[‘KeydisplayName’]))
Notice the quotes as others have mentioned but also the ‘keydisplayName’ I believe should be ‘KeydisplayName’.
I went through line by line and did each key and value making sure they all worked. I also found the same issue with the Type. I believe it should be capital in the expression.
Thanks for sharing your findings. I agree that quotes don’t copy well from blog posts but the key names in expressions are not case sensitive in cloud flows. Unless of course things have changed in the new designer? Item()[‘NaMe’] and item()?[‘name’] would both return a key called Name.
Thanks Tina, that solution worked for mee too.
In FilterArray I had to use:
@and(lessOrEquals(item().datediff,30), greaterOrEquals(item().datediff,-7))
Apply_To_Each is now For_Each:
addProperty(items(‘For_each’),’Owner’,body(‘HTTP_GetOwners’)?[‘value’]?[0]?[‘mail’])