Dynamic membership rule for Teams Room accounts

Teams Room accounts are usually excluded from conditional access. To do so, they have to be in a security group, which of course we don’t want to do manually.

Most companies choose to use a naming standard and simply use that as a rule to create an exclusion group. This is easy to circumvent, I can create a guest user with the right name et voila zero CA policies!

A better way is to identify the accounts based on their assigned licenses, e.g. Teams Rooms Basic (6af4b3d6-14bb-4a2a-960c-6c902aad34f3). This, however, is not supported as an Azure AD group membership rule as this is stored in the AssignedLicenses property which will throw an “Unsupported Property” error.

The assignedPlans property however, does not contain unique plans. E.g. Teams1 is also used in most other licenses. Same goes with mcomeetadv and whiteboard_plan3, this means we can’t use MS’s example for license-based rules, but we CAN compare all at once (a full / all match, instead of any).

The following rule only matches users that ONLY have TEAMS1, MCOMEETADV and WHITEBOARD_PLAN3:

(user.assignedPlans -all (assignedPlan.servicePlanId -in ["3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40","57ff2da0-773e-42df-b2af-ffb7a2317929","4a51bca5-1eff-43f5-878c-177680f191af"] -and assignedPlan.capabilityStatus -eq "Enabled")) -and
not (user.assignedPlans -all (assignedPlan.servicePlanId -eq ""))

Here’s another example that would match users with either MS Teams Basic or MS Teams Pro:

((user.assignedPlans -all (assignedPlan.servicePlanId -in ["3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40","57ff2da0-773e-42df-b2af-ffb7a2317929","4a51bca5-1eff-43f5-878c-177680f191af"] -and assignedPlan.capabilityStatus -eq "Enabled")) -or 
(user.assignedPlans -all (assignedPlan.servicePlanId -in ["41781fb2-bc02-4b7c-bd55-b576c07bb09d","3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40","4828c8ec-dc2e-4779-b502-87ac9ce28ab7","c1ec4a95-1f05-45b3-a911-aa3fa01094f5","57ff2da0-773e-42df-b2af-ffb7a2317929","0feaeb32-d00e-4d66-bd5a-43b5b83db82c","4a51bca5-1eff-43f5-878c-177680f191af","92c6b761-01de-457a-9dd9-793a975238f7"] -and assignedPlan.capabilityStatus -eq "Enabled")))
not (user.assignedPlans -all (assignedPlan.servicePlanId -eq ""))

if you want to do something similar for other licenses, here are the options/combinations:


Powershell Cert based authentication against the Graph API using a certificate from Keyvault

In automation scenarios it is common to use a service principal (app based) to work with the Graph API, or in my example, with PNP PowerShell against SharePoint (but both scenario’s work the same).

First, you’d need a client certificate, e.g. like this:

$folder = "c:\users\JosLieben\Desktop"
$cert=New-SelfSignedCertificate -Subject "CN=JOS" -CertStoreLocation "Cert:\CurrentUser\My"  -KeyExportPolicy Exportable -KeySpec Signature -HashAlgorithm "SHA256" -Provider "Microsoft Enhanced RSA and AES Cryptographic Provider"
Export-Certificate -Cert $cert -FilePath "$folder\jos.cer" 
Export-PfxCertificate -Password (ConvertTo-SecureString $clientCertPwd -AsPlainText -Force) -Cert $cert -FilePath "$folder\jos.pfx"

You’d then upload the .cer file as a certificate on your service principal to let Azure AD recognize your cert as a valid ‘password’ for your app registration.

Next you’d upload your .pfx file into Keyvault.

Finally, you can use Powershell to construct an access token for a given scope:

$tenantId = "YOURTENANTID"
$clientId = "YOURCLIENTID"
$scope = "https://graph.microsoft.com/.default" #or, e.g. https://$($tenantName)-admin.sharepoint.com/.default openid profile offline_access
$secret = Get-AzKeyVaultSecret -VaultName "YOURKEYVAULTNAME" -Name cippCert -AsPlainText
$clientCert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @([Convert]::FromBase64String($secret),"",[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable)

$header = @{
    alg = "RS256"
    typ = "JWT"
    x5t = [System.Convert]::ToBase64String(($clientCert.GetCertHash()))
} | ConvertTo-Json -Compress

$claimsPayload = @{
    aud = "https://login.microsoftonline.com/$tenantId/oauth2/token"
    exp = [math]::Round(((New-TimeSpan -Start ((Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime()) -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds),0)
    iss = $clientId
    jti = (New-Guid).Guid
    nbf = [math]::Round(((New-TimeSpan -Start ((Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime()) -End ((Get-Date).ToUniversalTime())).TotalSeconds),0)
    sub = $clientId
} | ConvertTo-Json -Compress

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

$preJwt = $headerjsonbase64 + "." + $claimsPayloadjsonbase64
$toSign = [System.Text.Encoding]::UTF8.GetBytes($preJwt)
$privateKey = $clientCert.PrivateKey
$alg = [Security.Cryptography.HashAlgorithmName]::SHA256
$padding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$signature = [Convert]::ToBase64String($privateKey.SignData($toSign,$alg,$padding)) -replace '\+','-' -replace '/','_' -replace '='

$jwt = $headerjsonbase64 + "." + $claimsPayloadjsonbase64 + "." + $signature

$Authbody = @{
    'tenant' = $tenantId
    'scope' = $scope
    'client_assertion_type' = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
    'client_id'     = $clientId
    'grant_type'    = 'client_credentials'
    'client_assertion' = $jwt

$accessToken = (Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ErrorAction Stop).accesstoken

The token can then be used to used to call Graph. And an example that shows how to use a sharepoint scoped token for the Sharepoint PNP PowerShell moddule:

#use scope: https://$($tenantName)-admin.sharepoint.com/.default

connect-PnPOnline -Url "https://$($tenantName)-admin.sharepoint.com" -AccessToken $accessToken -ReturnConnection

The importance of source OS when creating CIM images

When creating a CIM image for MSIX app attach, make sure that you’re using a lower or equal OS version than your target OS in e.g. Azure Virtual Desktop. If you use Windows 11 to create a CIM file and try to mount this on Windows 10, the result will be one of the following.

Azure Portal:

ActivityId: 1ef4d1ad-e4af-42d1-b6fa-139c45775efb Error: The MSIX Application metadata expand request failed on all Session Hosts that it was sent to. Session Host: xxxx1, Error: Native error when mounting CIM, HResult -2147024809, ErrorCode 87.. (Code: 400)

Using the CimDiskImage PS module:

Mount-CimDiskimage : Mounting xxxxx.cim to volume failed with Error:'The operation completed successfully Errorcode:0'
Mount-CimDiskimage : Mounting xxxx.cim to volume failed with Error:'Too many posts were made to a semaphore ErrorCode:298'

Above happens because W10 using V2 CIM disks, while W11 uses V3 CIM disks.

The mysterious X-MS-Forest header

When working with the api.interfaces.records.teams.microsoft.com API, I noticed that the MS portal uses an X-MS-Forest header.

At first, ignoring this went fine as doing GET calls to this api didn’t seem to require it. But, of course the moment I wanted more, it suddenly WAS required (PUT/POST).

The question was; how does the portal determine the value for this header and how do we replicate that? Well, that wasn’t difficult: apparently a call to api.interfaces.records.teams.microsoft.com/Teams.Tenant/tenants suffices and returns the value for the X-MS-Forest header for the tenant identified in your token. Example:

    $headers = Get-GraphToken -tenantid $tenantId -scope "https://api.interfaces.records.teams.microsoft.com/user_impersonation"
    #get the correct forest
    $tenantInfo = Invoke-RestMethod -Method GET -uri "https://api.interfaces.records.teams.microsoft.com/Teams.Tenant/tenants" -UseBasicParsing -ContentType "application/json" -Headers $headers
    #add the X-MS-Forest header (required) for subsequent calls
    $headers["X-MS-Forest"] = $tenantInfo.serviceDiscovery.Headers.'X-MS-Forest'

Microsoft 365, Azure, Automation & Code