Automatically Recover Zoom Licenses

Suppose you’ve purchased Zoom licenses for your users. However not everybody is using the services provided by having her/his user type as “Licensed”, instead some of the users can go along just fine with “Basic”. As you don’t want to pay for unused licenses, you don’t buy one for each and every user you have, and rely on assigning licenses to only those people that need it. However if auto-provisioning is enabled, any user automatically logging in will trigger an account to be created in Zoom. At this point you have a problem:

  • If a license is automatically handed out to every newly provisioned user, you’ll eventually run out of licenses and new users will be denied the services offered through one
  • If no license is automatically assigned to newly provisioned users, but instead any new account is provisioned as “Basic”, these won’t be able to use the licensed services

As you want to avoid buying more licenses, you’re left with only one alternative: free up licenses not used. You can go to Admin -> Account Management -> Reports -> Inactive Hosts and see those who haven’t logged in to their account in a specific timeframe and choose to remove their license. There are 2 problems with this approach, at least as of May 2020: 1) you have to process users one by one and 2) the timeframe of the report is limited to 1 month, so trying to get the users not active in the last 3 months is not that straightforward.

Luckily there’s an API that Zoom built, whose details are here that allows automating this process. Read on for how to build a script that leverages it for retrieving user data and for removing licenses for users matching a specific criteria.

TL;DR: Are you looking for a script that automatically identifies the users that haven’t logged in recently and removes their Zoom license ? Jump to the Powershell script.

The Plan

The high-level plan, which will be followed in this post, is as follows:

Authenticating

As described in Zoom’s API’s own documentation, you have 2 ways of authenticating to the Zoom API: 

  • OAuth2: There are 2 approaches of using this authentication method:
    • Authorization code (involving user consent)
    • Client credentials (the docs mention that this should be used only for the Chatbot Service)
  • JWT (JSON Web Token)

The OAuth way consists of at least one round-trip to the server (depending on which approach is taken) in order to obtain an access token. With JWT, you just build the token once on the client side, then use this in all subsequent requests to the API.

Whichever authentication way you want to go, you’ll need to use the Zoom marketplace to create an app in order to get started.

Additionally, as described here, unlike an OAuth app, you don’t need to consider security scopes when dealing with a JWT app – as it essentially gives you access to everything. In the light of the above we’ll be using a JWT app.

The Zoom JWT App

There can only be one single JWT app per Zoom account (account as in org-wide entity, not user account). This JWT app provides access to 2 key pieces of information that will be needed next: the API Key and the API Secret.

If there’s no JWT app created yet, in order to register a new one, you’ll need to make sure you have at least “Developer Role Permission” assigned to your account. Then follow the steps here. Don’t worry too much about the data in the “Information” tab (the details there won’t really matter) nor the “Feature” tab (Event subscriptions should stay disabled for our scenario), but ensure you do get an API Key and an API Secret generated on the “App Credentials” tab, and that you activate the app on the final “Activation” tab.

If a JWT app already exists, simply collect the API Key and the API Secret from the app’s details.

Creating the JSON Web Token

At this point we can quickly create a JWT token with one of the following:

  • The fast way: On the app’s “App Credentials” page, there’s a “View JWT Token” that allows creating a token for a specified duration. As specified in the Zoom docs, don’t use this in production (eg by choosing a long enough interval and reusing it for too long) due to obvious security reasons
  • The slower way: Not that straightforward as the previous point, this will however gets us on our way of automating the generation of the token further on. We’ll use the online JWT generator at https://jwt.io/ and we’ll build our token bit by bit on the “Decoded side”. Zoom’s own documentation about generating a JWT token is really nice and to the point. First thing is to double-check that the algorithm used in the online token generator is HS256. Then:
    • Take the header here – as it will stay the same for every token – and put it in the online token generator’s “Header” section
    • Take the sample payload here and paste it in the token generator’s “Payload” section. Replace the API_KEY entry with the value provided on the app’s “App Credentials” tab and use a Linux timestamp converter (eg this one) to generate a number for a date in the near future (eg +15 minutes to the current date); make sure that the API key – unlike the timestamp – stays enclosed in quotes
    • Take the “API Secret” from the app’s “App Credentials” tab and paste it in the editable field within the “Verify Signature” section; ensure that the checkbox on “secret base64 encoded” is turned off (if turned on, Zoom will refuse the token, complaining that the signature fails verification). The encoded token on the left is what you’ll need.

Testing that the token works can be done with a REST client (eg Insomnia https://insomnia.rest/), by making a simple GET to https://api.zoom.us/v2/users/me and specifying Bearer token as authentication together with the token obtained above (specifying the value as an OAuth2 access token should work as well). The result should contain a JSON with the details of the account that provisioned the app*.

Automating the Build of the JSON Web Token

It’s obvious that we’ll need to generate a JWT token on the fly, in order to automate calling the Zoom API. Let’s look at how to do this with Powershell.

Luckily Microsoft’s own documentation around SAS (shared access signature) tokens used in Azure, found here, can be used as starting point. It uses the same hashing algorithm we need (HMACSHA256) to compute the signature, it’s only that we’ll be using a different message as input, composed of the 3 bullet points we’ve seen in the previous section involving the online JWT generator.Therefore:

  • The header value’s base64 encoding will always be reused, as it never changes
  • The payload – containing a computed timestamp in the future and the API Key – is base64 encoded
  • The signature is computed against the string formed by concatenating the base64 encoded values of the header and the payload, connected by a dot. The key used for the HMAC hashing algorithm will be the API Secret. The result is base64 encoded to obtain the signature

The JWT token is simply the 3 pieces above linked together with 2 respective dots in-between.

We’ll do one additional thing: use base64url encoding. How does one get from base64url to base64 encoding ? There are 3 simple rules:

  1. Remove the padding =
  2. Convert + to - (the 62nd char in the Base64 alphabet)
  3. Convert / to _ (the 63rd char in the Base64 alphabet)

We’ll use String.Replace to avoid having the characters (eg +) treated as regex by -split.

Why bother converting everything to base64url encoding ? Because https://jwt.io enforces base64url, and it’s nice to be able to just take a token generated through code and quickly have it parsed in that tool, if only for troubleshooting purposes*.

The code for building the token thus becomes:

        ## HEADER
        # The header part stays the same: { "alg": "HS256", "typ": "JWT" }
        $headerBase64 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'

        ## PAYLOAD
        # Token expires: now + validForSeconds
        $Expires=([DateTimeOffset]::Now.ToUnixTimeSeconds()) + $validForSeconds
        $payload = "{`"iss`":`"$API_KEY`",`"exp`":$Expires}"
        $payloadAsBytesArray = [Text.Encoding]::ASCII.GetBytes($payload)
        $payloadBase64 = [Convert]::ToBase64String($payloadAsBytesArray)
        $payloadBase64Url = $payloadBase64.Replace('=','').Replace('+','-').Replace('/','_')

        ## SIGNATURE
        $SignatureString = $headerBase64 + "." + $payloadBase64Url
        $HMAC = New-Object System.Security.Cryptography.HMACSHA256
        $HMAC.key = [Text.Encoding]::ASCII.GetBytes($API_Secret)
        $SignatureAsBytesArray = $HMAC.ComputeHash([Text.Encoding]::ASCII.GetBytes($SignatureString))
        $SignatureBase64 = [Convert]::ToBase64String($SignatureAsBytesArray)
        # Convert to base64url (https://tools.ietf.org/html/rfc4648#section-5)
        #  We'll use String.Replace to avoid having (+) treated as regex by -split
        $SignatureBase64Url = $SignatureBase64.Replace('=','').Replace('+','-').Replace('/','_')
        $JWT_token = $headerBase64 + '.' + $payloadBase64Url + '.' + $SignatureBase64Url
        $JWT_token

Retrieving Data About the Users

We’re now ready to gather the list of users we’ll be targeting. Unfortunately at the present there are only 4 query parameters that can be used with the method that gets the list of Zoom users, with only 2 of those referring to specific user attributes. Neither the type of the user, nor the last login date are amongst them. In other words there’s no way to filter server side, so we’ll be forced to retrieve all the user accounts, then filter them client-side.

We’ll also have to manage paging of the results, as by default only 30 entries are returned per call (with a maximum of 300). This is controlled by the remaining 2 query parameters: one dictates the page size, and another specifies the result page that should be retrieved.

The queries can’t be fired off one after the other though. A “cooldown” period is required between each method call, as Zoom implements rate limits for some operations due to obvious security concerns. The various requests the Zoom API supports are ranked anywhere from “Light” to “Resource intensive”, with corresponding rate limits.Retrieving the list of users is considered a “medium” operation, and is limited to 20 requests/second for Pro accounts or 60 requests/second for Business, Education, Enterprise or Partner accounts as per https://marketplace.zoom.us/docs/api-reference/rate-limits#rate-limits. To accommodate either type of account, we’ll go for the most stringent restriction, and aim for 20 reqs/s. This comes down to a “cooldown” period of 50 ms.

With Powershell, in practice one doesn’t get to limit the wait time to anything as low as 50 ms. The reality shows that the actual time between REST calls sent down the wire – despite specifying a 50 ms wait in-between – is around 600 ms, for something as simple as getting information for just one Zoom user. This in turn will drive the time our JWT token needs to be valid, as in our script we’ll only request one when we start.

For each GET to https://api.zoom.us/v2/users, we’ll get back – among others – how many pages contain all our user entries, the total number of entries and a JSON array with the details for each user within the current page. Once imported to Powershell objects, the attributes on the user objects containing timestamps will be plain strings. The conversion to DateTime will be done using String‘s ToDateTime. And the very last step will be filtering against 1) users that are “Licensed” and that have 2) the last login timestamp older than 3 months, for the next stage of our processing.

Recovering Licenses

For each user we’ll be doing a PATCH against https://api.zoom.us/v2/users/{userId} as per the documentation. We already have the userId for all our users as part of the attributes present on the Powershell objects we retrieved in the previous section. As for the body of the request to be sent, this will be of type application/json, and the only thing we’re going to specify within is the new desired type of the user, which for “Basic” is 1. This simply translates to { "type": "1" }. Each conversion of a user’s type from “Licensed” to “Basic” will mean one extra license available.

In terms of rate limits, updating a user is in the same category as retrieving data about users, which translates to the same 50 ms needed between subsequent update requests.

For every successful call – regardless if the attributes to be changed are the same as the ones already present on the user object (eg setting type to 1 for a user that already has the type 1) – you’ll get back a HTTP 204 code.

The Powershell Script

The script that converts the Zoom “Licensed” users that haven’t logged in for more than 3 months, to “Basic”, is below.

You’ll need to provide the API Key and the API Secret, as detailed in the “The Zoom JWT App” section.

The valability time for the token which is issued when the script starts is 20 minutes, but can be altered directly inside the script, when the JWT token is built. The default value of 20 minutes will yield sufficient time for converting from “Licensed” to “Basic” around 1700 users. This includes time for a total number of Zoom users of around 10.000 (at a rate of 300 users/page assuming a maximum rate of requests of 1 per second (my tests with Invoke-RestMethod for a page with 300 user entries and monitoring via Fiddler yielded a time in-between consecutive requests of 1 second)), a few seconds for the internal conversion of the timestamps and updating around 1700 users (my tests showed around 700 ms / request for updating 1 user). If you expect more users to be updated, increase the token lifetime within the script.

Controlling the number of months that decides whether a user is to be targeted for license removal – which by default is 3 – can be changed using the -months parameter.

No external library (eg as part of a NuGet package) is needed to run the script, as only the default .NET Framework default types are used. In terms of Powershell version, anything running 3.0 or later should work just fine.

<#
.SYNOPSIS
Converts Zoom users that are "Licensed" but haven't logged in during the 
last <n> months to "Basic", thus freeing licenses
.PARAMETER API_Key
The API_Key that must be used to connect to the Zoom API, obtained from
the JWT App's "App Credentials" tab
.PARAMETER API_Secret
The API_Secret that must be used to connect to the Zoom API, obtained from
the JWT App's "App Credentials" tab
.PARAMETER NoOfMonthsForInactivity
The number of months a user that hasn't logged in is deemed as inactive, and
eligible to have its Zoom license removed
.EXAMPLE
.\RecoverZoomLicenses.ps1 -API_Key <key> -API_Secret <secret> -months 3
Switch all the Zoom users that haven't logged in the last 3 months from "Licensed" to "Basic"
#>
Param (
    [Parameter(Mandatory=$true)]
    [string]$API_Key,
    [Parameter(Mandatory=$true)]
    [string]$API_Secret,
    [Alias('months')]
    [int]$NoOfMonthsForInactivity = 3
)


function BuildZoomJWTtoken([string]$API_KEY,
    [string]$API_Secret,
    [int]$validForSeconds) {
        ## HEADER
        # The header part stays the same: { "alg": "HS256", "typ": "JWT" }
        $headerBase64 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'

        ## PAYLOAD
        # Token expires: now + validForSeconds
        $Expires=([DateTimeOffset]::Now.ToUnixTimeSeconds()) + $validForSeconds
        $payload = "{`"iss`":`"$API_KEY`",`"exp`":$Expires}"
        $payloadAsBytesArray = [Text.Encoding]::ASCII.GetBytes($payload)
        $payloadBase64 = [Convert]::ToBase64String($payloadAsBytesArray)
        $payloadBase64Url = $payloadBase64.Replace('=','').Replace('+','-').Replace('/','_')

        ## SIGNATURE
        $SignatureString = $headerBase64 + "." + $payloadBase64Url
        $HMAC = New-Object System.Security.Cryptography.HMACSHA256
        $HMAC.key = [Text.Encoding]::ASCII.GetBytes($API_Secret)
        $SignatureAsBytesArray = $HMAC.ComputeHash([Text.Encoding]::ASCII.GetBytes($SignatureString))
        $SignatureBase64 = [Convert]::ToBase64String($SignatureAsBytesArray)
        # Convert to base64url (https://tools.ietf.org/html/rfc4648#section-5)
        #  There are 3 rules:
        #    1. Remove the padding (=)
        #    2. Convert (+) to (-) [the 62nd char]
        #    3. Convert (/) to (_) [the 63rd char]
        #  We'll use String.Replace to avoid having (+) treated as regex by -split
        $SignatureBase64Url = $SignatureBase64.Replace('=','').Replace('+','-').Replace('/','_')
        $JWT_token = $headerBase64 + '.' + $payloadBase64Url + '.' + $SignatureBase64Url
        $JWT_token
}


$OAuthToken = BuildZoomJWTtoken -API_Key $API_Key -API_Secret $API_Secret -validForSeconds 1200
$zoomUsersEndpoint = "https://api.zoom.us/v2/users"

# The number of the page is 1-based
$pageNumber = 1

# Initialize the number of pages. This will be overwritten when reading the first page
$noOfPages = 0

# We'll also use the maximum allowed number of entries per page
$MAX_NO_ENTRIES_PER_PAGE = 300

# This will hold the list of all the users returned
$global:users = @()

do {
    # Send the first REST call request without any paging parameter
    # By default, only 'active' users are retrieved, as per https://marketplace.zoom.us/docs/api-reference/zoom-api/users/users
    $params = @{
                Method = 'GET'
                URI = $zoomUsersEndpoint
                Headers = @{ Authorization = "Bearer $OAuthToken" }
                Body = @{ page_number = $pageNumber
                          page_size = $MAX_NO_ENTRIES_PER_PAGE } 
    }
    try {
        $global:response = Invoke-RestMethod @params
    }
    catch {
        Write-Host "REST call threw exception: ($_.Exception)"
        $global:exc = $_
        exit
    }

    # If this is the first request, then obtain how many pages there are overall
    if($pageNumber -eq 1) {
        $noOfPages = $response.page_count
        Write-Host "$noOfPages pages of results, containing $($response.total_records) entries"
    }

    # Add the current batch of users returned in the current page to our array
    $global:users += $response.users
    
    # Enter a delay so we don't hit the rate limits https://marketplace.zoom.us/docs/api-reference/rate-limits#rate-limits
    Start-Sleep -Milliseconds 50
    Write-Host -NoNewline "."

    $pageNumber++
} while ($pageNumber -le $noOfPages)

Write-Host

# Copy all the columns to another object, but convert to DateTime the ones that store
#  time info as they're currently plain strings
$global:parsedUsers = $users | Select-Object id, first_name, last_name, email, type, pmi, timezone, verified,
    @{label="createdAt";expression={$_.createdAt.ToDateTime([CultureInfo]::new("en-us"))}},
    @{label="lastLoginTime";expression={$_.last_login_time.ToDateTime([CultureInfo]::new("en-us"))}},
    last_client_version, language, phone_number, status

 # Set the cutover date over which we consider users as inactive   
$cutoffDate = (Get-Date).AddMonths(-$NoOfMonthsForInactivity)
# Get the users that are licensed and haven't logged in the chosen window
$global:usersToHaveTheLicenseRemoved = $parsedUsers | ? { $_.type -eq 2 -and $_.lastLoginTime -lt $cutoffDate }

Write-Host "$(($global:usersToHaveTheLicenseRemoved | Measure-Object).count) users to be converted away from `"Licensed`""
Write-Host "Type 'yes' to continue converting these users from `"Licensed`" to `"Basic`""
$confirmText = Read-Host

if($confirmText -ne "yes") {
    exit
}

# For all the eligible users to be converted, send the REST request
#  to convert the user from "Licensed" to "Basic"
$global:usersToHaveTheLicenseRemoved | % {
    $currentUserId = $_.id
    $params = @{
        Method = 'PATCH'
        URI = "$zoomUsersEndpoint/$currentUserId"
        Headers = @{ Authorization = "Bearer $OAuthToken"
                     'Content-Type' = 'application/json' }
        Body = "{ `"type`": `"1`"}"
    }
    try {
        $global:response = Invoke-RestMethod @params
    }
    catch {
        Write-Host "REST call threw exception: ($_.Exception)"
        $global:exc = $_
        exit
    }

    # Enter a delay so we don't hit the rate limits https://marketplace.zoom.us/docs/api-reference/rate-limits#rate-limits
    Start-Sleep -Milliseconds 50
    Write-Host -NoNewline "."
}

Q&A

Q: I’m doing the GET to https://api.zoom.us/v2/users/me with a valid token, and I get back valid data. However this belongs to the Zoom account owner, not to the user that registered the Zoom app. Why is this so ?
A: This is actually by design. As per this link https://devforum.zoom.us/t/whether-i-can-use-me-default-as-userid-or-not-when-use-jwt-api-with-userid/14927/2, “[…]since you can only have 1 JWT app for all your Zoom users, it will only return the info for the Zoom Account Owner if you use me“.

Q: I’m using https://jwt.io to test if the JWT token I’ve built is valid, but I get “Invalid Signature”. I’ve even taken a generated string from the Zoom portal, which is guaranteed to work, and I still get the same message. What’s going on ?
A: The signature within the JWT token is verified using the HMAC key, which in our case is the “API_Secret” value. The online decoder https://jwt.io can’t extract this info from anywhere other than “Verify signature”‘s editable secret field on the right. If the field is left as-is when the page loads, then you’ll get a generic value in there; similarly if you’ve generated test tokens with this portal using various secrets, the last value will be left in there. In both cases, they’ll be different from whatever secret you used to generate the token you’ve pasted in the “Encoded” field. The solution: paste the secret you’ve used in the “Verify signature”‘s secret field, and you should now get “Signature Verified”.

Q: I’m trying to manually compute the HMACSHA256 signature, but never get it to verify. I’m using this online tool https://www.freeformatter.com/hmac-generator.html, but – regardless if I use the output directly or base64 encode it before using – the signature is always invalid. I know the tool is good, so what’s happening here ?
A: That tool, at least as of May 2020, is returning the signature as a hex string. You can’t use that as is, nor use a regular ASCII to base64 online encoder to obtain a valid value. Let’s go through an example: suppose the first 4 hex digits of the signature are “e6684”. This needs to be converted to base64, and the way to do this correctly is to convert to binary first. So “e” becomes “1110”, each of the next “6” turns to “0110”, the “8” turns to “1000” and the “4” turns to “0100”. Base64 operates on a sequence of 6 bits (2^6=64), so let’s put our binary digits in a continuous string, and introduce a separator every 6th bit: 111001 100110 100001 00….  Now let’s use the base64 alphabet described here to encode our data: “111001” converts to 57 in decimal, which the base64 alphabet codifies as “5”; “100110” converts to 38 in decimal, which the base64 alphabet codifies as “m”; “100001” converts to 33 in decimal, which the base64 alphabet codifies as “h”, and so on. So the first 3 base64 characters of our result will be “5mh”. It’s this text that will be the token’s signature.Contrast what we’ve done above with simply converting the hex string itself to base64, which is what you get when simply throwing “e6684” in a regular base64 encoder. This time it’s the ASCII representation that is used. Using the ASCII table against the same “e6684” example above, and ensuring 8-bits representations are used, we codify as follows: “e” becomes “01100101”, each of the next “6” turns to “00110110”, the “8” turns to “00111000” and the “4” turns to “00110100”. Concatenating the bits, and using groups of 6 as needed for base64, we get: 011001 010011 011000 110110 001110 000011 0100…  Let’s use again the base64 alphabet to encode our data: “011001” converts to 25 in decimal, which the base64 alphabet codifies as “Z”; “010011” converts to 19 in decimal, which the base64 alphabet codifies as “T”; “011000” converts to 24 in decimal, which the base64 alphabet codifies as “Y”, “110110” converts to 54 in decimal, which the base64 alphabet codifies as “2”; “001110” converts to 14 in decimal, which the base64 alphabet codifies as “O”; “000011” converts to 3 in decimal, which the base64 alphabet codifies as “D”, and so on. The first characters of our result are this time “ZTY2OD”. Totally different from the “5mh” we’ve obtained previously.

This is discussed on different sources as well. Have a look here too.

Q: I’ve checked and I can successfully send the token in base64 only. So why go to the trouble to do base64url ?
A: You can supply the signature either as base64 or base64url encoded, and the Zoom API will accept both. You can do the same with the payload, only that you need to be consistent in computing the signature – eg don’t generate the signature against the base64 encoded payload, but include the base64url encoded payload in the token itself. Base64url encoding was used throughout the article to specifically have the token validated within https://jwt.io, which enforces base64url.

Q: I’d like to see the actual traffic when the Invoke-RestMethod is called. How can I do this ?

A: One way of seeing the traffic sent down the wire is to use Fiddler. Using a regular Powershell window should make the traffic show up instantly in Fiddler; if using Insomnia, you might have to configure it to use Fiddler’s default local proxy port (by default :8888).

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s