O365 License Names: It’s a mess!

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?

O365 license names are just GUIDs

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:

O365 license evaluation of the tenant

A view in the Azure AD portal:

AzureAD portal dont show the names

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:

O365 portal dont show the names

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:

Getting licenses with the MSOL Powershell dont give readable names

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:

The scripts creates a readable name mapping for the O365 licenses

You can easily grab one of these products to get more info:

Playing around with the Product List

The “Plan” property contains info about service plans, their IDs, GUIS, and display names:

Mapping for O365 license service plans to readable 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 🙂 .

5 thoughts on “O365 License Names: It’s a mess!

  1. Awesome! And totally hilarios that we have to parse a website for this… Thx so much!

  2. 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

  3. 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

    1. 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 ?

Leave a Reply

Your email address will not be published. Required fields are marked *