This post is about resolving license SKU IDs into readable O365 license names. It’s not as easy as you might expect. Ok, we learned how to get the list of O365 licenses that are assigned to an Azure AD account:
Get-AzureADUser -SearchString "Bob Bobman" | Get-AzureADUserLicenseDetail | Select Sku*
In our example, the lucky guy Bob has a bunch of high-quality licenses, but we don’t see that too clearly in the output, do we? Where are the normal display names for the O365 licenses?
Although we evaluated only two properties in this ‘AzureADUserLicenseDetail’ object – believe me: Also in the rest of the properties, we will not find any display names that are readable… Same situation if we try to look at license details with other cmdlets:
A view in the Azure AD portal:
Ahh – here they are: Product Names for the assigned licenses. But without the GUIDs and SkuPartNumbers (the technical SKU String IDs like “EMSPREMIUM”). Maybe we find something in the O365 portal:
Nope, no relationship between technical SKUs and display names visible.
Last try – we use the old MSOL PowerShell module:
(Get-MsoLUser -SearchString "Bob Bobman").Licenses.AccountSkuId
The technical SKU IDs are shown together with the company name (this is the first part ‘xyz’ in the @xyz.onmicrosoft.com tenant domain name). But no display name is available in this API neither:
The Problem
Unfortunately, there is NO possibility by API scripting to get a display name of a product or service plan if you only have the corresponding SKU ID – or vice versa. In none of the APIs we can use with PowerShell (including Microsoft Graph). 🙁
At least, Microsoft published in 2019 a document with a list of SKUIDs, GUIDs and display names:
https://docs.microsoft.com/en-us/azure/active-directory/users-groups-roles/licensing-service-plan-reference
Although there is a statement ‘Microsoft does not plan to update them for newly added services periodically’, they updated this document several times in the past to reflect (the frequent) changes in licensing, products and SKUs.
The Solution
So I made a script which reads this website. This script extracts the table with product names and SKU IDs and GUIDS so that you have two arrays as a result:
- $products – License Products with display names, SKU IDs, GUIDs – and a list of included service plans (like ‘sub products’).
- $plans – Service plans are the building blocks of the products. They also have (suprise!) display names, ID Strings and GUIDs – which are also only visible in this list on the Microsoft website.
By the way – I had to use quite inconvenient regular expression evaluation and string manipulation of the HTML content. The default approach to extract the table content would have been much easier – something with
(Invoke-WebRequest $url).ParsedHtml.getElementsByTagName("TABLE")
… but that gave me a freeze or very very long timeout on this website content (an issue with the ‘ParsedHtml’ method which apparently occurs quite often). So I decided to go there ‘on foot’. Here is the script:
$products = @()
$plans = @()
$planIDs = @{}
#read the content of the Microsoft web page and extract the first table
$url = "https://docs.microsoft.com/en-us/azure/active-directory/users-groups-roles/licensing-service-plan-reference"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$content = (Invoke-WebRequest $url -UseBasicParsing).Content
$content = $content.SubString($content.IndexOf("<tbody>"))
$content = $content.Substring(0, $content.IndexOf("</tbody>"))
#eliminate line feeds so that we can use regular expression to get the table rows...
$content = $content -replace "`r?`n", ''
$rows = (Select-String -InputObject $content -Pattern "<tr>(.*?)</tr>" -AllMatches).Matches | % {
$_.Groups[1].Value
}
#on each table row, get the column cell content
# 1st cell contains the product display name
# 2nd cell contains the Sku ID (called 'string ID' here)
# 3rd cell contains the included service plans (with string IDs)
# 3rd cell contains the included service plans (with display names)
$rows | % {
$cells = (Select-String -InputObject $_ -Pattern "<td>(.*?)</td>" -AllMatches).Matches | % {
$_.Groups[1].Value
}
$productName = $cells[0]
$productStringID = $cells[1]
$productGUID = $cells[2]
$plansWithString = $cells[3]
$plansWithName = $cells[4]
#build an object for the product
$product = New-Object -TypeName psobject
$product | Add-Member -MemberType NoteProperty -Name Name -Value $productName
$product | Add-Member -MemberType NoteProperty -Name SkuID -Value $productStringID
$product | Add-Member -MemberType NoteProperty -Name GUID -Value $productGUID
$product | Add-Member -MemberType NoteProperty -Name Plans -Value @()
if (($plansWithString.Trim() -ne '') -and ($plansWithName.Trim() -ne '')) {
#store the service plan string IDs for later match
$plansWithString -split "<br.?>" | % {
$s = $_.Trim()
if ($s) {
$planStringID = ($s.SubString(0, $s.LastIndexOf('('))).Trim()
$planGUID = $s.SubString($s.LastIndexOf("(") + 1)
if ($planGUID.Contains(')')) {
$planGUID = $planGUID.SubString(0, $planGUID.IndexOf(')'))
}
if (-not $planIDs.ContainsKey($planGUID)) {
$planIDs.Add($planGUID, $planStringID)
}
}
}
#get te included service plans
$plansWithName -split "<br.?>" | % {
$s = $_.Trim()
if ($s) {
$planName = ($s.SubString(0, $s.LastIndexOf('('))).Trim()
$planGUID = $s.SubString($s.LastIndexOF("(") + 1)
if ($planGUID.Contains(')')) {
$planGUID = $planGUID.SubString(0, $planGUID.IndexOf(')'))
}
#build an object for the service plan
$plan = New-Object -TypeName psobject
$plan | Add-Member -MemberType NoteProperty -Name Name -Value $planName
$plan | Add-Member -MemberType NoteProperty -Name StringID -Value $planIDs[$planGUID]
$plan | Add-Member -MemberType NoteProperty -Name GUID -Value $planGUID
if ($plans.GUID -notcontains $planGUID) {
$plans += $plan
}
$product.Plans += $plan
}
}
}
$products += $product
}
Write-Host "`nYou can use `$produtcs and `$plans now...." -ForegroundColor Cyan
The Result: Readable O365 License Names
So you can run the script or integrate this into your own scripts. After that, you know the readable O365 license names. And you can use the $products and $plans arrays for your own needs, for example like this:
You can easily grab one of these products to get more info:
The “Plan” property contains info about service plans, their IDs, GUIS, and display names:
Cool, isn’t it?
So from now on (and until Microsoft takes the info website offline or changes the table structure in there), you can resolve License Product Names to SKU IDs 🙂 .
6 thoughts on “O365 License Names: It’s a mess!”
for the people that try to just get the tenantname for the yourcompany:licencesku here is a little script to get the first part to put that company name in a variable
Connect-MsolService
get-msoldomain | Export-Csv c:\temp\domain\domainvalue.csv
$domainarray = Import-Csv c:\temp\domain\domainvalue.csv
$domainarray.Name
Start-Sleep -Seconds 3
$tenantname1 = @($domainarray.name) -match ‘.onmicrosoft.com’
$tenantname2 = $tenantname1.split(“.”) | Select-Object -Index 0
write-host $tenantname2
Awesome! And totally hilarios that we have to parse a website for this… Thx so much!
I can’t get correct values for $plan
your code return me this:
Exception calling “Substring” with “2” argument(s): “Length cannot be less than zero.
Parameter name: length”
At line:25 char:17
+ … $planStringID = ($s.SubString(0, $s.LastIndexOf(‘(‘))).T …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : ArgumentOutOfRangeException
Hello Philipp,
as someone who tries to wrestle with AzureAd Licensing and recently combing through the same page, I didn’t get to _just_ parsing the page. Thank you for doing this work! It is much appreciated.
May I use the code you’ve posted in a Script of mine to form the baseline for my Licensing Functions in TeamsFunctions?
Currently I have defined 39 different Licenses (containing Teams1) and 13 ServicePlans relevant for Teams in my two Variables, but it would make much more sense to form it into a separate function 🙂
The Author spot in the Script would obviously be yours 🙂
Thanks,
David
Hello Philipp, thanks for sharing this article.
I try to run the script but I am getting error.
Exception calling “Substring” with “2” argument(s): “Length cannot be less than zero.
Parameter name: length”
At C:\temp\Get-MS-Sku-Details.ps1:46 char:13
+ $planStringID = ($_.SubString(0, $_.LastIndexOf(“(“))).T …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : ArgumentOutOfRangeException
Can you help me ?
Hi Marcus, thanks for giving the hint. Microsoft changed the structure of the table a bit. I did some adjustments to avoid trying adding $null string into a dictionary :). Try out the new script version, should be OK now. You could also check out this other blog post of mine with a huge list of SKUIDs: https://scripting.up-in-the.cloud/licensing/list-of-o365-license-skuids-and-names.html