As a quick reference, partially for myself, these are identifiers for my setup (So you can more easily understand the screenshots aswell):
Now, for authentication we have several goals, each addressing different flows as documented by Microsoft:
Let’s start on the autonomous part. I will be working directly with the HTTP calls for getting tokens, to ensure we understand what is going on here.
The Microsoft documentation contains the below picture, which illustrates what we need to do:

At the same time, I will be implementing these new methods into Fortytwo’s EntraIDAccessToken PowerShell module. From a protocol standpoint, there is one new thing to take care of here, and that is the new fmi_path parameter for the token endpoint.

This is essentially a way for us to say to Entra ID that we need a token for authenticating as this particular Agent Identity. This also means that if we have two different Agent Identities, we cannot request a single Blueprint token and use that to authenticate to both Agent Identities, instead we need two Blueprint tokens, each with different fmi_path values.
Step 1 – Getting token for the Blueprint
In the below request, we use a client secret (not recommended, but nice for testing) to authenticate as the Agent Identity Blueprint (9471f355-173a-4466-b142-3d4acf848b03), providing the fmi_path of cd77c677-16ea-4f9d-b5b1-0aab1841694c. which is Blogpost Agent Identity 1.
POST https://login.microsoftonline.com/237098ae-0798-4cf9-a3a5-208374d2dcfd/oauth2/v2.0/token
client_id=9471f355-173a-4466-b142-3d4acf848b03
&client_secret=n97...
&fmi_path=cd77c677-16ea-4f9d-b5b1-0aab1841694c
&grant_type=client_credentials
&scope=api://AzureADTokenExchange/.default
Looking at the returned access token is not really all that interesting. The audience is fb60f99c-7a34-4190-8149-302f77469936, as expected (The appid of the token exchange), but the subject looks somewhat interesting. The last part of it contains the value we provided in the fmi_path, so this is apparently used for targeting the access token (As it is treated stateless in Entra).
{
"typ": "JWT",
"alg": "RS256",
"kid": "PcX98GX420T1X6sBDkzhQmqgwMU"
}.{
"aud": "fb60f99c-7a34-4190-8149-302f77469936",
"iss": "https://login.microsoftonline.com/237098ae-0798-4cf9-a3a5-208374d2dcfd/v2.0",
"iat": 1770040571,
"nbf": 1770040571,
"exp": 1770044471,
"aio": "ASQA2/8bAAAA3gn55pV1NWN6lu6zGbb2FVlDEe3TjuRLEyYH6WS1thc=",
"azp": "9471f355-173a-4466-b142-3d4acf848b03",
"azpacr": "1",
"idtyp": "app",
"oid": "bf31e1f8-803f-4e95-bcc4-6d1008c09f0c",
"rh": "1.AUsArphwI5gH-UyjpSCDdNLc_Zz5YPs0epBBgUkwL3dGmTYAAABLAA.",
"sub": "/eid1/c/pub/t/rphwI5gH-UyjpSCDdNLc_Q/a/VfNxlDoXZkSxQj1Kz4SLAw/cd77c677-16ea-4f9d-b5b1-0aab1841694c",
"tid": "237098ae-0798-4cf9-a3a5-208374d2dcfd",
"uti": "VC6mMmJisEGiNDlxhhlNAA",
"ver": "2.0",
"xms_act_fct": "9 3",
"xms_ficinfo": "CAAQABgAIAAoAjAA",
"xms_ftd": "SB_ad3RCDp430LUHcVbmihDpGAwu_vVGAWbECqf8bbMBZXVyb3Blbm9ydGgtZHNtcw",
"xms_idrel": "26 7",
"xms_sub_fct": "3 9"
}
Step 2 – Using the blueprint token to get a token for the Agent Identity
Now we can send our second request, where we use the token from Step 1 to get the token we actually want:
POST https://login.microsoftonline.com/237098ae-0798-4cf9-a3a5-208374d2dcfd/oauth2/v2.0/token
client_id=cd77c677-16ea-4f9d-b5b1-0aab1841694c
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&grant_type=client_credentials
&scope=https://graph.microsoft.com/.default
&client_assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IlBjWDk4...
From this, we will now get a new access token – the token we actually want:
{
"typ": "JWT",
"nonce": "lk2jWoiTdE6qzF2le5VuD7ZQrVXe1Rozm2Ep159Ojyo",
"alg": "RS256",
"x5t": "PcX98GX420T1X6sBDkzhQmqgwMU",
"kid": "PcX98GX420T1X6sBDkzhQmqgwMU"
}.{
"aud": "https://graph.microsoft.com",
"iss": "https://sts.windows.net/237098ae-0798-4cf9-a3a5-208374d2dcfd/",
"iat": 1770041239,
"nbf": 1770041239,
"exp": 1770045139,
"aio": "k2ZgYIh7aKAZ5P0xSPe3c2OscNQzjpTr5lOdP4dP+qJklxbHpgsA",
"app_displayname": "Blogpost Agent Identity 1",
"appid": "cd77c677-16ea-4f9d-b5b1-0aab1841694c",
"appidacr": "2",
"idp": "https://sts.windows.net/237098ae-0798-4cf9-a3a5-208374d2dcfd/",
"idtyp": "app",
"oid": "cd77c677-16ea-4f9d-b5b1-0aab1841694c",
"rh": "1.AUsArphwI5gH-UyjpSCDdNLc_QMAAAAAAAAAwAAAAAAAAAAAAABLAA.",
"sub": "cd77c677-16ea-4f9d-b5b1-0aab1841694c",
"tenant_region_scope": "EU",
"tid": "237098ae-0798-4cf9-a3a5-208374d2dcfd",
"uti": "FCPTgFNbZ0-4hNoqwYtNAA",
"ver": "1.0",
"wids": [
"0997a1d0-0d1d-4acb-b408-d5ca73121e90"
],
"xms_act_fct": "3 11 9",
"xms_ftd": "JgOsjwEiCLhubn6bkEtOah3cTMQdNLrYTB3L8MDuhXsBZXVyb3Bld2VzdC1kc21z",
"xms_idrel": "7 24",
"xms_par_app_azp": "9471f355-173a-4466-b142-3d4acf848b03",
"xms_rd": "0.42LlYBJi9BYS4WAXEuDzP_880TvYd_4Unyld6UtXAUU5hQQuBks4qHAqOm9YPZHxTfH1EKAoh5AAMwMEHIDSQFFuIYE1PBPetSpmBa-tnsDPv49luhQfB5cQl6G5uYGBiYGpuSEA",
"xms_sub_fct": "9 3 11",
"xms_tcdt": 1681123817,
"xms_tdbr": "EU",
"xms_tnt_fct": "3 6"
}
Here we can see that audience is Microsoft Graph, that the appid is the client id of the Blogpost Agent Identity 1 and we have a claim xms_par_app_azp that points to the blueprint app id.
I have added agent identity stuff into the open source EntraIDAccessToken PowerShell module, where we can do things very simple:
# Step 1 - Add a client secret access token profile for the Blueprint (can be replaced with other access token profiles)
Add-EntraIDClientSecretAccessTokenProfile -TenantId "237098ae-0798-4cf9-a3a5-208374d2dcfd" -ClientId "9471f355-173a-4466-b142-3d4acf848b03" -Scope "api://AzureADTokenExchange/.default" -Name "Blueprint"
# Step 2 - Add a federated credential access token profile for Agent 1, using the Blueprint access token profile as federated credential
Add-EntraIDFederatedCredentialTokenProfile -Name "Agent 1 Graph" -TenantId "237098ae-0798-4cf9-a3a5-208374d2dcfd" -ClientId "cd77c677-16ea-4f9d-b5b1-0aab1841694c" -FederatedAccessTokenProfile Blueprint -AgentIdentity
# Get and print the access token
Get-EntraIDAccessToken -Profile "Agent 1 Graph" | WAT

Up until now, we have not talked about which permissions the Agent Identity has. We have been able to get an access token for Microsoft Graph, however, we have not talked about what we can access..
Something that is new with the blueprint pattern, is inheritable permissions. These are permissions that are inherited from the blueprint, by the Agent Identities. We can now add an inheritable permission to Microsoft Graph like this:
POST https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/9471f355-173a-4466-b142-3d4acf848b03/inheritablePermissions
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"inheritableScopes": {
"@odata.type": "microsoft.graph.enumeratedScopes",
"scopes": [
"User.Read",
"User.Read.All",
"GroupMember.Read.All"
]
}
}
However, this is not for application permissions! These are only for delegated permissions. That means that in order to get the above permissions, we need a user signing in (On behalf of flow), but in this blog post we are talking about the autonomous agent flow. OBO flow will be covered later.
In order to get application permissions in our access token scope (roles claim), there is no admin consent UI or anything we can use. Instead we need to create an app role assignment on the Microsoft Graph service principal (Which is the underlying admin consent feature…). The EntraIDAccessToken module has a cmdlet New-EntraIDAppPermission, that can help us with this.
New-EntraIDAppPermission -Permission User.Read.All,GroupMember.Read.All -ObjectId cd77c677-16ea-4f9d-b5b1-0aab1841694c

Now we can use our Agent 1 to get access token (Using the Blueprint) and we can see that correct roles:
Add-EntraIDClientSecretAccessTokenProfile -TenantId "237098ae-0798-4cf9-a3a5-208374d2dcfd" -ClientId "9471f355-173a-4466-b142-3d4acf848b03" -Scope "api://AzureADTokenExchange/.default" -Name "Blueprint"
Add-EntraIDFederatedCredentialTokenProfile -Name "Agent 1" -TenantId "237098ae-0798-4cf9-a3a5-208374d2dcfd" -ClientId "cd77c677-16ea-4f9d-b5b1-0aab1841694c" -FederatedAccessTokenProfile Blueprint -AgentIdentity -Scope "https://graph.microsoft.com/.default"
Get-EntraIDAccessToken -Profile "Agent 1" | Write-EntraIDAccessToken
This will now return a JWT with the following scopes:

And we can now validate that we can use the token to read users:
Invoke-RestMethod "https://graph.microsoft.com/v1.0/users" -Headers (GATH -Profile "Agent 1") | Select-Object -ExpandProperty value | Select-Object id, displayName

Ok, so that was Graph application permissions covered. Now, what about other accesses?
Accessing Azure is very, very simple. We can simply grant access to the Agent Identity (And as you can see, we can also grant access to the Agent Identity user):

After this, we can get an access token for management.azure.com and access things:
Add-EntraIDClientSecretAccessTokenProfile -TenantId "237098ae-0798-4cf9-a3a5-208374d2dcfd" -ClientId "9471f355-173a-4466-b142-3d4acf848b03" -Scope "api://AzureADTokenExchange/.default" -Name "Blueprint"
Add-EntraIDFederatedCredentialTokenProfile -Name "Agent 1" -TenantId "237098ae-0798-4cf9-a3a5-208374d2dcfd" -ClientId "cd77c677-16ea-4f9d-b5b1-0aab1841694c" -FederatedAccessTokenProfile Blueprint -AgentIdentity -Scope "https://management.azure.com/.default"
Invoke-RestMethod "https://management.azure.com/subscriptions?api-version=2020-01-01" -Headers (GATH -Profile "Agent 1")

Have you noticed this? Yes, agents can actually request access packages!

And also, we can now add application permissions to access packages!

So, I have created an access package that All agents can request, with approval, that grants the Group.Create application permission:


So for now (Until I figure out the agent can request the access by itself), I first tried adding a request through MyAccess, but failed (I believe this SHOULD work, but it doesn’t):

So instead, I tried to add the request like this:

That worked, and I got a pending approval:

And we can see that the access package is being delivered:

And this was then stuck forever with an error saying Request_BadRequest: One or more properties are invalid
The reason why this happens, is that there are several application permissions that is blocked for being assigned to Agent Identities. I could not find a comprehensive list of these.
I changed the access package to groupmember.read.all instead, which I know is available to Agent Identities, and it worked:

And after this, I can see my access token changing with a new role claim!

So to summarize this, rather than admin consent being the workflow, it should now be access packages. The access packages should be requested either by the agent itself, or by the manager or sponsor (Which currently seem not to work in MyAccess).
Ok, so now we have the autonomous flow in place. In the next blog post we will look at the Agent user flow before we tackle the On behalf of flow.
]]>Ehm, where do we start? Well, Microsoft has gotten quite good at documentation, so the What is Microsoft Entra Agent ID? page is a really good start. The way I write these kinds of blog posts is not in a “I already learned this, let me give you the TLDR”-kind of way, but instead a “I have an OK impression on what this is already, but I don’t really have all the details. Now, let me write while I experiment.”-kind of way…
Reading the documentation, we first find this fancy diagram, that essentially highlights a lot of things that will be involved in a complete solution, based on Entra. The protocols listed in black, being the Model Context Protocol (MCP) and Agent 2 Agent Protocol (A2A) are both open protocols, allowing for interaction between agents and from agents to APIs. This is all nice, but being an identity nerd, I am really more interested in the inner workings of the purple stuff – Authentication and Authorization, at least initially, as I believe this will give insight into how we can actually understand what these agents are doing.

So for now, I am going to ignore everything the documentation says about agent registry, governance, etc., just digging straight into this: Agent identity and blueprint concepts in Microsoft Entra ID.
In Entra ID, we have up until now had the a few simple facts:
With agents, we are getting some new stuff, partially building on top of existing components.
Let’s say we are a company that wants to build an agent, where you might have many customers that all want one or more instances of your agent. The Agent Identity Blueprint is only created once for this agent, and contains information about things like who you are as a publisher, the name of the agent, available app roles, etc. – all things that we today have on app registrations. And of course maybe the most important part – credentials for our agent(s)!
When any of your customers onboard the agent, an Agent identity blueprint principal is created – the instance of your Agent identity blueprint. 100% how application registrations and service principals work today.
Let’s create one, following this documentation:

Well this failed.. So, I tried doing the same operation as an app with lots of permissions:

Install-Module EntraIDAccessToken -Scope CurrentUser -Force
Add-EntraIDClientSecretAccessTokenProfile
$body = '{ "@odata.type": "Microsoft.Graph.AgentIdentityBlueprint",
"displayName": "Blogpost Agent 1",
"[email protected]": ["https://graph.microsoft.com/v1.0/users/61858a99-231e-4b5d-9960-79d10c15d76f"],
"[email protected]": ["https://graph.microsoft.com/v1.0/users/61858a99-231e-4b5d-9960-79d10c15d76f"]
}'
Invoke-RestMethod "https://graph.microsoft.com/beta/applications/" -Method Post -Body $body -ContentType "application/json" -Headers (Get-EntraIDAccessTokenHeader -AdditionalHeaders @{"OData-Version"="4.0"})

After being able to create the agent blueprint, we can now create an Agent identity blueprint principal:
$body = '{"appId": "9471f355-173a-4466-b142-3d4acf848b03"}'
Invoke-RestMethod "https://graph.microsoft.com/beta/serviceprincipals/graph.agentIdentityBlueprintPrincipal" -Method Post -Body $body -ContentType "application/json" -Headers (Get-EntraIDAccessTokenHeader -AdditionalHeaders @{"OData-Version"="4.0"})

Following the doc without knowing what I do, I also add an oauth2 permission scope to the Agent Identity Blueprint:
$body = '{
"identifierUris": ["api://9471f355-173a-4466-b142-3d4acf848b03"],
"api": {
"oauth2PermissionScopes": [
{
"adminConsentDescription": "Allow the application to access the agent on behalf of the signed-in user.",
"adminConsentDisplayName": "Access agent",
"id": "c4fa0790-b77f-4b67-ae81-6acd14d47a61",
"isEnabled": true,
"type": "User",
"value": "access_agent"
}
]
}
}'
Invoke-RestMethod "https://graph.microsoft.com/beta/applications/9471f355-173a-4466-b142-3d4acf848b03" -Method Patch -Headers (gath -AdditionalHeaders @{"OData-Version"="4.0"}) -ContentType "application/json" -Body $body

Right now, we can find our Agent Identity Blueprint by going to entra.microsoft.com, finding Agent ID and scrolling down to find Agent blueprints:

Navigating into the blueprint, there is not really all that much that can be done:

In summary for now, we have essentially just created an app registration and a service principal for it, just providing an OData type, that instead makes it an Agent identity blueprint. There is one thing to note, and that is that our newly created blueprint has a permission that has been automatically admin consented for us – the permission AgentIdentity.CreateAsManager:

So the point here now is that our Agent Identity Blueprint can be used to create Agent Identities! This again means that we need to authenticate as the Agent Identity Blueprint, so we add a client secret to it (Or any other type of credential):
Invoke-RestMethod "https://graph.microsoft.com/beta/applications/9471f355-173a-4466-b142-3d4acf848b03/addPassword" -Method Post -Headers (gath -AdditionalHeaders @{"OData-Version"="4.0"}) -ContentType "application/json"
This means that we are ready to look into the next object type – the Agent Identity:
As I said, we will now sign in with out Agent Identity Blueprint (for me, this is app id 9471f355-173a-4466-b142-3d4acf848b03):
Add-EntraIDClientSecretAccessTokenProfile -ClientId 9471f355-173a-4466-b142-3d4acf848b03 -TenantId 237098ae-0798-4cf9-a3a5-208374d2dcfd
Get-EntraIDAccessToken | Write-EntraIDAccessToken
We can of course now see that we have the permissions we expect to find:

We can now imagine that the act of allowing a type of agent, either internally built or bought from an external vendor, is the consent to the Agent Identity Blueprint, which will allow the blueprint to create Agent Identities.
For creating these identities, we need to give it a name and a sponsor, as well as pointing to the Blueprint id.
$body = @{
agentIdentityBlueprintId = "9471f355-173a-4466-b142-3d4acf848b03"
displayName = "Blogpost Agent Identity 1"
"[email protected]" = @("https://graph.microsoft.com/v1.0/users/61858a99-231e-4b5d-9960-79d10c15d76f")
}
Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity" -Headers (Get-EntraIDAccessTokenHeader -AdditionalHeaders @{"OData-Version" = "4.0" }) -Method Post -ContentType "application/json" -Body ($body | ConvertTo-Json)

After creating this agent identity, we can find it in the user interface:


What we will now also see, is that we can create several agent identities:
$body = @{
agentIdentityBlueprintId = "9471f355-173a-4466-b142-3d4acf848b03"
displayName = "Blogpost Agent Identity 2"
"[email protected]" = @("https://graph.microsoft.com/v1.0/users/61858a99-231e-4b5d-9960-79d10c15d76f")
}
Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity" -Headers (Get-EntraIDAccessTokenHeader -AdditionalHeaders @{"OData-Version" = "4.0" }) -Method Post -ContentType "application/json" -Body ($body | ConvertTo-Json)

The point here, is that this can easily be multiple agents, based on the same blueprint, but maybe granted different permissions etc.. We can also note that the objectid og and appid of the created Agent Identity is the same:

Out of interest, I also tried adding a client secret to this service principal, but that is blocked, thankfully:

Let’s summarize the things we have created and understood for now:

And translating the same pattern to a multi tenant / vendor configuration, we can have the blueprint in the vendor tenant, and the Blueprint Principal and Agent Identities in different customer tenants:

There is one more object we need to talk about, that I have not seen used too much yet, which is the Agent User. The need for such an object arises when we need features that does not work for applications, such as having their own mailbox. Agent Users are different from our regular users in a few ways, with the most important bit being the fact that they do not have passwords. Instead, they rely on a federated credential from the Agent Identity. This means that there is no way for this user to do an interactive sign-in.
Anyway, let’s create one. It is the Blueprint Principal that is responsible for creating the user, but the permission is not granted by default. This is because the user is optional, and there is no need to grant permissions that will never be used.
A bit harder to find the documentation for these Agent Users, but here it is.
First, we need to grant our Agent Blueprint the required permissions. Apparently this is not possible in the UI:

Ok, so I know how to grant a permission like this, but I would like my blueprint to have the correct permission listed in the required permissions list. So, working with Graph explorer, I can find all blueprints in my tenant using this url:

What I thought I was going to find here, was the requiredResourceAccess property. However, this is not present on Blueprints… Ok, let’s grant the permission in a different way:
New-EntraIDAppPermission -Permission AgentIdUser.ReadWrite.IdentityParentedBy -ObjectId bf31e1f8-803f-4e95-bcc4-6d1008c09f0c

After this, I can find the permission listed in the UI:

Now I can finally run this as our Blueprint service principal:
$body = @{
"@odata.type" = "microsoft.graph.agentUser"
accountEnabled= $true
displayName = "Blogpost Agent Identity 1 User"
mailNickname = "7acd14d47a62"
userPrincipalName = "[email protected]"
identityParentId = "cd77c677-16ea-4f9d-b5b1-0aab1841694c"
}
Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/users/" -Headers (Get-EntraIDAccessTokenHeader) -Method Post -ContentType "application/json" -Body ($body | ConvertTo-Json)

At this point, we can see that our agent identity (#1) has a user assigned to it:


So right now, we have the following objects set up:

To find our objects, we have the following Graph urls available to us:
| Object type | Url |
| Agent Identity Blueprints | https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint |
| Agent Identity Blueprint Principals | https://graph.microsoft.com/beta/servicePrincipals/microsoft.graph.agentIdentityBlueprintPrincipal |
| Agent Identities | https://graph.microsoft.com/beta/servicePrincipals/microsoft.graph.agentIdentity |
| Agent Users | https://graph.microsoft.com/beta/users/microsoft.graph.agentUser |
Now that we have our objects created and operational, the next step is to actually use them to authenticate. This will be my next blog post 
When I do integrations like this, I want to make them stateless whenever possible, as the complexity is just so much lower with no state to manage at all. After digging around the Graph endpoint documentation and doing some testing, I found that when we upload a new picture, Microsoft is doing some kind of processing of the image data that we upload, removing metadata and saving it as JPEG.
This means that we can upload image data to a user, get the data back and it will not match:
Install-Module EntraIDAccessToken -Scope CurrentUser
Add-EntraIDInteractiveUserAccessTokenProfile -Scope "https://graph.microsoft.com/ProfilePhoto.ReadWrite.All"
# Upload picture
invoke-restmethod "https://graph.microsoft.com/v1.0/users/08c83ffb-0cf3-4094-aa17-a200814d1a90/photo/`$value" -Headers (Get-EntraIDAccessTokenHeader) -Method Put -InFile .\illustratio.png -ContentType "image/png" -Debug -Verbose
# Download the picture back
invoke-restmethod "https://graph.microsoft.com/v1.0/users/08c83ffb-0cf3-4094-aa17-a200814d1a90/photo/`$value" -Headers (Get-EntraIDAccessTokenHeader) -OutFile downloaded.png
And even though we expect the same data to be downloaded, we get a different file:

So, we are unable to check that the image data is actually already correct between Entra ID and HR… Annoying! There is one solution available though, which is the image ETag:
$wr = Invoke-WebRequest "https://graph.microsoft.com/v1.0/users/08c83ffb-0cf3-4094-aa17-a200814d1a90/photo/`$value" -Headers (Get-EntraIDAccessTokenHeader)
$wr.Headers.ETag
This will return a value like W/”f7ffb4e2935883c4cdadceb0c829e594a7297b3a72b6aa3281c3b7613a0f9367″, which is not deterministic – meaning it will be a new value each time we upload a new picture – even though the picture data is the same exact binary data. So, in a world where we maintain the state, we could store this value along with the sha256 hash of the uploaded image data, and we could compare them.
Actually, this is even more annoying, because there is no way to $select or $expand the photos property of users:

So in order to detect that users uploaded a new photo, we would need to GET the photo metadata of each user, which of course takes for ever.
So what is the solution here? Well, the way we ended up doing at this customer is to do the following:
Is it a good solution? No, not really, but it works.
]]>
Clicking this button thousands of times is not really how I wanted to spend my evening, so we therefore wanted to figure out which users were affected, and triggering a reprocess for all of these.
In order to achieve this, we use PowerShell 7, with the Microsoft Graph module. First we connect and get all access packages and assignments from our tenant:
Install-Module Microsoft.Graph -Scope CurrentUser
Connect-MgGraph -Scopes "EntitlementManagement.ReadWrite.All", "Group.Read.All"
$groupcache = @{}
Write-Verbose "Getting all access packages"
$accessPackages = Get-MgEntitlementManagementAccessPackage -All -ExpandProperty "ResourceRoleScopes(`$expand=role,scope)" -Debug -Verbose
Write-Verbose "Getting all access package assignments"
$assignments = Get-MgEntitlementManagementAssignment -All -PageSize 999 -ExpandProperty "AccessPackage", "Target"
Next, we create a for each loop that loops through all assignments for all access packages, and for each group these are assigning permissions for – to create a PSCustomObject. This means that the $result variable will be a list of all assignments, with the IsMember property set to true or false, and we are looking for the false ones:
Write-Verbose "Create report over access package assignments and group memberships"
$result = $accessPackages | ForEach-Object {
$accessPackage = $_ # For dev purposes: $accessPackage = $accessPackages | get-random -count 1
$_assignments = $assignments | Where-Object { $_.AccessPackage.Id -eq $accessPackage.Id }
if (!$_assignments) {
Write-Verbose "No assignments found for access package: $($accessPackage.DisplayName)"
return
}
$count = $_assignments | measure-object | select-object -expandproperty count
Write-Verbose "Found $count assignments for access package: $($accessPackage.DisplayName)"
$accessPackageMemberGroups = $accessPackage.ResourceRoleScopes | Where-Object { $_.scope.originSystem -eq "AadGroup" -and $_.role.originId -like "Member_*" } | foreach-object { $_.scope.originId }
if ($accessPackageMemberGroups) {
$accessPackageMemberGroups | foreach-object {
Write-Verbose "Processing group ID: $_"
$groupcache[$_] ??= Get-MgGroup -GroupId $_ -ErrorAction SilentlyContinue
if ($groupcache[$_]) {
$groupcache["$($_)_members"] ??= Get-MgGroupMember -GroupId $groupcache[$_].Id -All -PageSize 999
$memberCount = $groupcache["$($_)_members"] | measure-object | select-object -expandproperty count
Write-Verbose "Group $($groupcache[$_].DisplayName) has $memberCount members."
$membersMap = ($groupcache["$($_)_members"] | Group-Object -Property Id -AsHashTable) ?? @{}
$_assignments | ForEach-Object {
[PSCustomObject]@{
AccessPackageName = $accessPackage.DisplayName
AccessPackageId = $accessPackage.Id
AssignmentId = $_.Id
AssignmentState = $_.State
AssignmentStatus = $_.Status
AssignedTo = $_.Target.Id
AssignedToUPN = $_.Target.PrincipalName
AssignedToSubjectType = $_.Target.SubjectType
AssignedToDisplayName = $_.Target.DisplayName
GroupName = $groupcache[$_].DisplayName
GroupId = $groupcache[$_].Id
IsMember = $membersMap.ContainsKey($_.Target.Id)
}
}
}
else {
Write-Warning "Group with ID $_ not found."
}
}
}
else {
Write-Verbose "Access package: $($accessPackage.DisplayName) does not grant membership to any groups."
}
}
We can now use the $result variable, looping through it and calling the reprocess endpoint:
# Output all results
$result | Out-gridview
# Find missing memberships
$missing = $result | Where-Object AssignmentState -ne "Expired" | Where-Object IsMember -eq $false
# Invoke reprocess for any assignment that has missing memberships
$_group = $missing | Group-Object AssignmentId
$inc = 0
$_group | ForEach-Object {
$inc += 1
Write-Progress -Activity "Invoking reprocess for missing memberships" -Status "Processing assignment $inc of $($_group.Count)" -PercentComplete (($inc / $_group.Count) * 100)
$entry = $_.Group[0]
Write-Output "Invoking reprocessing for`n - Assignment id: $($entry.AssignmentId)`n - User: $($entry.AssignedToUPN)`n - Access package: $($entry.AccessPackageName)"
Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/assignments/$($entry.AssignmentId)/reprocess" | Out-Null
}
Write-Progress -Activity "Invoking reprocess for missing memberships" -Completed -Status "Done"
Let’s quickly talk about how the module works. In general, you do two things:
The module takes care of everything else:
This means that you can simply do things like below, or as in one of our modules, without worrying about how the access token is actually retrieved, as long as you have added the access token profile first:
Invoke-RestMethod "https://graph.microsoft.com/v1.0/users" -Headers (Get-EntraIDAccessToken)
Invoke-RestMethod "https://myvault.vault.azure.net/secrets/test" -Headers (Get-EntraIDAccessToken -Profile "keyvault")
Invoke-RestMethod "https://api.fortytwo.io/echo" -Headers (Get-EntraIDAccessToken -Profile "fortytwo")
Now, let’s have a look on how to add the profiles. We can start with a simple client secret:
# Read the client secret
$clientsecret = Read-Host -AsSecureString
# Add the access token profile
Add-EntraIDClientSecretAccessTokenProfile -ClientSecret $clientsecret -ClientId 4445f44b-3d99-4472-b03c-4b87062a2ff5 -TenantId 237098ae-0798-4cf9-a3a5-208374d2dcfd
# Get access token and write pretty to screen
Get-EntraIDAccessToken | Write-EntraIDAccessToken

Notice how Get-EntraIDAccessToken simply uses the profile added earlier, and can be called at any time. Also, notice how the IAT claim only changes if we as Get-EntraIDAccessToken to ForceRefresh using a parameter (or if the token expires), which means that it caches the token for us:

We can also do the same thing with certificate based authentication by first creating a self signed certificate and exporting it to a .cer file
New-SelfSignedCertificate -Subject "GoodWorkaround" -NotAfter (get-date).AddYears(100)
$certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) | Set-Content -AsByteStream "cert.cer"
The file is then uploaded to the app registration:

And then we can simply add our access token profile like this:
# Add the access token profile
Add-EntraIDClientCertificateAccessTokenProfile -Thumbprint "6D4E8134B401BBC3FF476957A95D849A4FEACA44" -ClientId 4445f44b-3d99-4472-b03c-4b87062a2ff5 -TenantId 237098ae-0798-4cf9-a3a5-208374d2dcfd
# Get access token and write pretty to screen
Get-EntraIDAccessToken | Write-EntraIDAccessToken
Notice how the Get-EntraIDAccessToken works the same way no matter how authentication actually happens? This is the point of the module. You actually have a whole list of cmdlets for configuring the access token profile:
You will need delegated permissions for AccessReview.Read.All, as well as a role that allows you to manage access reviews.
# Connect to the Microsoft Graph with the required scopes
Connect-MgGraph -Scope "AccessReview.Read.All"
# Get all access review defintions
Write-Host "Getting access review definitions"
$Definitions = Get-MgIdentityGovernanceAccessReviewDefinition -All -PageSize 100 -Verbose
# Filter to only those we care about
# $Definitions = $Definitions | Where-Object {$_.Scope.AdditionalProperties.query -like "*accessPackageAssignments*"}
$Definitions = $Definitions | Where-Object { $_.DisplayName -like "Teams review" }
# For each definition, get the latest completed instance
$Report = foreach ($Definition in $Definitions) {
# Use to step through: $Definition = $Definitions | get-random -count 1
Write-Host "Processing $($Definition.Status) AR: $($Definition.DisplayName) ($($Definition.Id))"
$Instances = Get-MgIdentityGovernanceAccessReviewDefinitionInstance -AccessReviewScheduleDefinitionId $Definition.id -All
$Instance = $Instances | Where-Object Status -in @("Completed","Applied") | Sort-object EndDateTime -Descending | Select-Object -First 1
if (!$Instance) {
Write-Warning "No active or completed instances found for $($Definition.DisplayName) ($($Definition.Id)) "
}
else {
# Get all decisions for the instance
$Decisions = Get-MgIdentityGovernanceAccessReviewDefinitionInstanceDecision -AccessReviewInstanceId $Instance.Id -AccessReviewScheduleDefinitionId $Definition.id -All
# Process each decision, outputting a custom object for each
foreach ($Decision in $Decisions) {
# Use to step through: $Decision = $Decisions | get-random -count 1
[PSCustomObject] @{
Definition_DisplayName = $Definition.DisplayName
Definition_DescriptionForReviewers = $Definition.DescriptionForReviewers
Definition_Status = $Definition.Status
Decision_Principal_DisplayName = $Decision.Principal.DisplayName
Decision_Principal_Id = $Decision.Principal.Id
Decision_Principal_UserPrincipalName = $Decision.Principal.userPrincipalName ?? $Decision.Principal.AdditionalProperties.userPrincipalName
Decision_Decision = $Decision.Decision
Decision_Recommendation = $Decision.Recommendation
Decision_Resource_DisplayName = $Decision.Resource.DisplayName
Decision_Resource_Id = $Decision.Resource.Id
Decision_Resource_Type = $Decision.Resource.Type
Decision_ReviewedBy_DisplayName = $Decision.ReviewedBy.DisplayName
Decision_ReviewedBy_Id = $Decision.ReviewedBy.Id
Decision_ReviewedBy_UserPrincipalName = $Decision.ReviewedBy.UserPrincipalName ?? $Decision.ReviewedBy.AdditionalProperties.userPrincipalName
Instance_Id = $Instance.Id
Instance_StartDateTime = $Instance.StartDateTime
Instance_EndDateTime = $Instance.EndDateTime
Decision_ApplyResult = $Decision.ApplyResult
}
}
}
}
# To use Export-Excel, run this first: Install-Module ImportExcel -Scope CurrentUser
$Report | Export-Excel -Path ".\AccessReviewReport.xlsx" -AutoSize -AutoFilter -WorksheetName "AccessReviews" -TableName "AccessReviews" -Show -ClearSheet
From the script you will get a lot of columns in Excel, where the name should be pretty much self explanatory:


Let’s dig into this feature!
In this blog post we will assume that you have an Active Directory and Entra ID, with Entra Connect Sync set up as follows:

What is important now, is that you have version 2.5.76.0 or later of Entra Connect Sync, as this enables the Group SOA feature:

In total we will do two things to a group:
These are two separate steps. In this section we will do the first. Let’s go!
In our setup we have create three security groups:

The operation of switching the source of authority is really simple, but there is no user interface currently, so we’ll use the Graph Explorer.
Make sure you are signed in and click Consent to permissions:

Find the permission Group-OnPremisesSyncBehavior.ReadWrite.All and click Consent (Already done in this screenshot):

Let’s start by looking at Sec group 1, with objectid 62f954e4-4da2-461e-9df5-d8991842b233:
GET https://graph.microsoft.com/beta/groups/62f954e4-4da2-461e-9df5-d8991842b233/onPremisesSyncBehavior
Notice the new onPremisesSyncBehavior endpoint, where we can find information about whether a group has been converted to cloud or not:

We can see that isCloudManaged is false, and we can update it to true:

After this patch is now true:

Ok? So what happened now? Let’s have a look at our list of groups:

Sec group 1 is now Cloud managed and no longer managed by Windows Server AD!
We can do this like updating the description:

And manage members:

We can of course now handle the group through services like Microsoft Graph, Entitlement Management, Lifecycle workflows and PIM for Groups too, just like any other Entra ID group.
Now, what happened on the Entra ID Connect Sync side of things?
If we use the Synchronization Service Manager, we can find that blockOnPremisesSync has been set to true in the connector space of the Entra ID connector:

And if we remove a user from the group in Active Directory:

The sync engine will show that something is going on with the group, but the export to Entra ID will simply not happen:


So we have now successfully converted our group from an Active Directory group to an Entra ID group, but as of now, the groups needs to be maintained separately. We can choose to delete the AD group completely, never caring about it again, or we can establish writeback to the group. Let’s try both.

Ok, so the converted group was deleted from AD. What happens in Entra ID Connect Sync now?
Well, the sync engine notices the delete and does a provisioning disconnect:


But there is no exported delete to Entra ID:

The group has now been fully removed from Active Directory!
In order to allow Entra ID to control the group, we will use a feature that already exists in Entra ID Connect Cloud Sync, the Provision Microsoft Entra ID to Active Directory feature (Group writeback as it was once called). This means that if you have Entra Connect Sync today, you will be running Cloud sync as well:

Let’s start by converting Sec group 2 to Entra ID managed:

We can now quickly configure the write-back as follows:

In this scenario I will simply target Sec group 2, but we can of course target multiple groups, scoped by attributes, etc:

Now comes one anoying thing. After using the Provision on demand feature, we get an error:


The error HybridSynchronizationActiveDirectoryInvalidGroupType tells us that a global security group is not supported, so we will need to convert it to universal!

After this, it works just fine:

Worth noticing is that this configuration by default will move the group to another OU and update the cn of the group. Also, Entra ID has no group reconciliation feature, meaning that if you go into AD and add or remove a member – Entra ID will not know about this. Only changes happening in Entra ID will be processed, meaning that if you add a member in Entra ID, it will be added in AD as well.
So, that’s it. We can now finally take Entra ID to the next level by limiting the need to access Active Directory directly 

The script will output a text based report summarizing the attribute usage, as well as Out-GridView (Excel-ish) each entry, allowing you to figure out exactly which groups are using which attributes.
Simply store the below file as file.ps1, connet to Microsoft graph using Connect-MgGraph -Scope Group.Read.All and run the file using . ./file.ps1
[CmdletBinding()]
Param(
[Parameter(Mandatory = $false)]
[Switch] $IgnoreProcessingState
)
# Get all dynamic groups
$criteriaGroups = Get-MgGroup -All -Property id, displayName, membershipRule, membershipRuleProcessingStatus, membershipRuleProcessingState -Filter "groupTypes/any(c:c eq 'DynamicMembership')"
$count = $criteriaGroups | Measure-Object | Select-Object -ExpandProperty Count
if($count -eq 0) {
Write-Host "No dynamic groups found."
} else {
Write-Host "$count dynamic groups found."
# Loop through each group and get the membership rule
$criteriaReport = $criteriaGroups |
ForEach-Object {
$group = $_ # $group = $criteriaGroups | get-random -count 1
Write-Host "Processing group $($PSStyle.Foreground.BrightYellow)'$($group.displayName)'$($PSStyle.Reset) ($($group.id))"
# Check if the group is in 'On' state
if(!$IgnoreProcessingState.IsPresent -and $group.MembershipRuleProcessingState -ne "On") {
Write-Host "Group $($group.displayName) is not in 'On' state. Skipping..." -ForegroundColor Yellow
return
}
# Extract the attributes from the membership rule
$attributes = [Regex]::Matches($group.MembershipRule, 'user\.[a-zA-Z0-9_]+')
if($attributes) {
$attributes.Value |
ForEach-Object {$_.Replace('user.', '')} |
Sort-Object -Unique |
ForEach-Object {
[PSCustomObject] @{
GroupName = $group.displayName
GroupId = $group.id
Attribute = $_
}
}
} else {
Write-Host "Unable to find any user attributes in the membership rule for group $($group.displayName). This can be because it is a memberof group query, or a device group."
}
}
Write-Host "Found $($criteriaReport.Count) attribute criteria in the membership rules:"
$criteriaReport |
Group-Object attribute |
Sort-Object Name |
ForEach-Object {
Write-Host " - Attribute $($PSStyle.Foreground.BrightYellow)$($_.Name)$($PSStyle.Reset) is used by $($PSStyle.Foreground.BrightYellow)$($_.Count)$($PSStyle.Reset) groups"
}
$criteriaReport |
Out-GridView
}
This is how to take all members of an Entra ID group and assigning them to an access package using PowerShell.
# Configure these values:
$accesspackageid = "4dbc5088-7ba9-4d81-a4c1-51595581ba1d"
$assignmentpolicyid = $null # Leave as null to use the first assignment policy for the access package
$group = "38520aee-c2c9-4900-8405-862902df2c88"
# No need to do anything below this line
Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser
Connect-MgGraph -Scopes EntitlementManagement.ReadWrite.All, User.Read.All, Group.Read.All
# Get the access package, assignment policy and existing assignments
$accessPackage = Get-MgEntitlementManagementAccessPackage -AccessPackageId $accesspackageid -ExpandProperty catalog, assignmentPolicies
$assignmentpolicyid ??= $accessPackage.AssignmentPolicies.id | Select-Object -First 1
$assignments = Get-MgEntitlementManagementAssignment -Filter "accessPackage/id eq '$($accesspackageid)'" -ExpandProperty target -All
# Create a map of existing assignments
$assignmentsMap = $assignments | ? state -eq "delivered" | Group-Object -AsHashTable -Property { $_.Target.ObjectId }
$assignmentsMap ??= @{}
# Get all group members and foreach over them
$groupMembers = Get-MgGroupMember -GroupId $group -All
$groupMembers | ForEach-Object {
if ($assignmentsMap.ContainsKey($_.id)) {
Write-Host "User $($_.id) already assigned to access package $($accessPackage.DisplayName)"
}
else {
if ($accessPackage.AssignmentPolicies.RequestApprovalSettings.IsApprovalRequiredForAdd) {
Write-Warning "Approval required for adding user to access package $($accessPackage.DisplayName), this should be disabled before adding users this way"
}
else {
Write-Host "Adding user $($_.id) to access package $($accessPackage.DisplayName)"
New-MgEntitlementManagementAssignmentRequest -Assignment @{
targetId = $_.id
assignmentPolicyId = $accessPackage.AssignmentPolicies.id
accessPackageId = $accessPackage.Id
} -RequestType "adminAdd" | Out-Null
}
}
}
There is a little caveat though, and that is that you will still need another app registration, but by using this new preview, we no longer need client secrets or certificates. Let’s have a look at how this works!
Let me explain what we are going to configure in this blog post:

So, as you can see, there are a few components involved, but let’s go!
Creating the user assigned identity
Let’s start by creating a user assigned identity:

After creating it, we need the object id for later:

Creating the app registration
Now we’ll create our multi tenant app registration. This is what tenant B will consent to.
Go to Entra ID > App registrations and click + New registration

Give it a proper name and select Accounts in any organizational directory (Any Microsoft Entra ID tenant – Multitenant). We do not need any redirect uris.

Note down the tenant id and the client id of the created app registration:

Please note: This is a multi tenant application. We actually do not need the enterprise app created in tenant A at all, and no access to tenant A to be consented. It is just that the UI always created a service principal / enterprise app. If you create the app reg using New-MgApplication this is not created.
Navigate to Certificates & Secrets > Federated credentials and blick +Add credential:

Choose the scenario Other issuer and use https://login.microsoftonline.com/{tenant}/v2.0 as the issuer (remember to replace the tenant with the tenant id) and use the object id of the user assigned identity as the value:

If you are in any “other” Microsoft Cloud, find your audience values here, or otherwise leave it default.

Next, find API permissions, and add Microsoft Graph User.Read.All scope. Leave it unconsented, as you are now in tenant A, where we do not need any permissions at all:

Consenting in tenant B
Now that the app registration has been created, go to tenant B and consent by navigating to this url: https://login.microsoftonline.com/common/adminConsent?client_id={client id of the app registration}
You should be seeing something like this, where our app requests access to read all users’ full profiles:

After accepting, ignore the error message AADSTS500113: No reply address is registered for the application. This is normal for this type of application.
In tenant B you will now find the following enterprise app, but you will find no app registration for this application:

Using the identity in an automation account
We have now created all required resources except for the automation account that will be using the identity. You could also use a virtual machine, logic app, function app, web site or any other service that supports managed service identities for this purpose.
During the creation process of the automation account, we specify the user assigned identity we created under the Advanced tab:


After creating the automation account, let’s create a runbook using PowerShell 7.2:


Coding the runbook
The runbook is actually pretty simple, split into four steps:
#region Step 1 - Set variables
# The client id of the user assigned identity we created
$userAssignedIdentityClientId = "ffdf511c-1c76-4107-bf29-4ab7cbfb887e"
# The client id of the app registration we created
$appRegistrationClientId = "422f2ff6-689e-490f-a930-4515d3d92f5f"
# The tenant id of tenant A
$appRegistrationTenantId = "bb73082a-b74c-4d39-aec0-41c77d6f4850"
# Tenant id of tenant B
$targetTenantId = "237098ae-0798-4cf9-a3a5-208374d2dcfd"
#endregion
#region Step 2 - Authenticate as the user assigned identity
$accessToken = Invoke-RestMethod $env:IDENTITY_ENDPOINT -Method 'POST' -Headers @{
'Metadata' = 'true'
'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER
} -ContentType 'application/x-www-form-urlencoded' -Body @{
'resource' = 'api://AzureADTokenExchange'
'client_id' = $userAssignedIdentityClientId
}
if(-not $accessToken.access_token) {
throw "Failed to acquire access token"
} else {
Write-Output "Successfully acquired access token for user assigned identity"
}
#endregion
#region Step 3 - Exchange the access token from step 2 for a token in the target tenant using the app registration
$accessTokenForTenantB = Invoke-RestMethod "https://login.microsoftonline.com/$targetTenantId/oauth2/v2.0/token" -Method 'POST' -Body @{
client_id = $appRegistrationClientId
scope = 'https://graph.microsoft.com/.default'
grant_type = "client_credentials"
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
client_assertion = $accessToken.access_token
}
if(-not $accessTokenForTenantB.access_token) {
throw "Failed to acquire access token for tenant B"
} else {
Write-Output "Successfully acquired access token for tenant B"
}
#endregion
#region Step 4 - Call the Microsoft Graph API using the token from step 3
$r = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users?`$top=999" -Headers @{
Authorization = "Bearer $($accessTokenForTenantB.access_token)"
} | Select-Object -Exp Value | Measure-Object
Write-Output "Found $($r.Count) users in tenant B ($targetTenantId)"
#endregion
Running the runbook should look something like the following:

If we create it down a bit more in detail, on the access token level, we can see that the access token that we receive in step 2 is issued with the subject field set to the object id of the user assigned identity, and signed by tenant A:

This matches the federated credential that we added to the app registration:

However, looking at the access token that we get in step 3, the issuer and roles and everything is different:

This is actually signed by tenant B, since we have the consent.
Using this approach we can now easily access resources cross tenant without any need for any secrets anywhere. Awesome!
Anyway, hope that helps someone!
]]>