Use the Microsoft Graph API and the Samsung KME API to automatic replace profile tokens for Android Enterprise dedicated devices

Thought to share how I have automated the renewal of tokens for Android Enterprise dedicated devices profile in MEM and Samsung KME. I found some people who have blogged about some steps but none that have automated it all the way. The difficult step for me was to use the Samsung KME API as I didn’t want to use their own assemblies to generate and sign JWT access token with the keys.json file.

Why I don’t want to use Samsung’s own assemblies is because you have to pass the path to the keys.json file instead of pass it as a string. This means that I can’t store it securely by using Azure Key Vault. Since I like to use Logic Apps I had to create an Azure Function that I can trigger to generate an access token by passing the clientId and keys.json as strings.

Components

  • Azure Functions App
  • Azure Key Vault
  • Logic Apps

Azure Functions App

Note: See the blog post from Nicola Suter that I’ve linked below for instructions on how use Advanced Tools to upload the dll’s to the Modules folder in the Azure Function App

Dll’s

Note: The code is based on the blog post from Tobias Almen but instead of using Samsung’s assemblies I build my own code to handle the certificate and signing. See his blog post that I have linked below for more information how to get started with the Samsung KME API

GetKnoxJWTAccessToken

<#
.SYNOPSIS
    Azure Function to generate and sign JWT access token.
.DESCRIPTION
    This function uses third party assemblies to generate and sign JWT access token by passing the client identifier and the content of keys.json as strings. To call this function you first need to set up API access in the Samsung Knox portal and generated a client identifier and download the keys.json file.
    
    All needed assemblies can be download from the nuget homepage. https://www.nuget.org/packages

    Portable.BouncyCastle 1.9.0
    JWT 8.6.0
    Newtonsoft.Json 10.0.3 
.NOTES
    Written by: Tobias Renström
    Blog: https://cloudnstuff.com/
    Version: 1.0
#>

using namespace System.Net

# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)

[Reflection.Assembly]::LoadFile("C:\home\site\wwwroot\Modules\BouncyCastle.Crypto.dll") | Out-Null
[Reflection.Assembly]::LoadFile("C:\home\site\wwwroot\Modules\JWT.dll") | Out-Null
[Reflection.Assembly]::LoadFile("C:\home\site\wwwroot\Modules\Newtonsoft.Json.dll") | Out-Null

#region functions
function Get-RsaParameters {
    param(
        [Parameter(Mandatory = $true)]
        [System.String]$privateKey
    )

    $byteArray = [System.Text.Encoding]::ASCII.GetBytes($privateKey)
    $ms = [System.IO.MemoryStream]::new($byteArray)
    $sr = [System.IO.StreamReader]::new($ms)
    $pemReader = [Org.BouncyCastle.OpenSsl.PemReader]::new($sr)
    $pemObj = $pemReader.ReadObject()
    $pubSpec = [Org.BouncyCastle.Crypto.Parameters.RsaKeyParameters]::new($false, $pemObj.Modulus, $pemObj.PublicExponent)
    $keyPair = [Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair]::new($pubSpec, $pemObj)

    return [Org.BouncyCastle.Security.DotNetUtilities]::ToRSAParameters($keyPair.Private)
}

function Get-RS256JWTEncoder {
    param(
        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.RSAParameters]$rsaParams
    )

    $csp = [System.Security.Cryptography.RSACryptoServiceProvider]::new()
    $csp.ImportParameters($rsaParams)
    $algorithm = [JWT.Algorithms.RS256Algorithm]::new($csp, $csp)
    $serializer = [JWT.Serializers.JsonNetSerializer]::new()
    $urlEncoder = [JWT.JwtBase64UrlEncoder]::new()

    return [JWT.JwtEncoder]::new($algorithm, $serializer, $urlEncoder)
}

function Get-SignedClientIdentifier {
    param(
        [Parameter(Mandatory = $true)]
        $certificate,
        [System.String]$clientId,
        [Parameter(Mandatory = $false)]
        [System.Int32]$validForSeconds = 1800
    )

    $privateKey = "-----BEGIN PRIVATE KEY-----`n" + $certificate.Private + "`n-----END PRIVATE KEY-----"
    $publicKey = $certificate.Public

    $createTime = [System.Int32][System.Double]::parse((Get-Date -Date $((Get-Date).ToUniversalTime()) -UFormat %s))
    $expiryTime = [System.Int32][System.Double]::parse((Get-Date -Date $((Get-Date).addseconds($validforSeconds).ToUniversalTime()) -UFormat %s))

    $payload = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new()
    $payload.Add('clientIdentifier', "$clientId")
    $payload.Add('publicKey', "$publicKey")
    $payload.Add('jti', "$([guid]::NewGuid())")
    $payload.Add('aud', "KnoxWSM")       
    $payload.Add('exp', "$expiryTime")
    $payload.Add('iss', "Knox")
    $payload.Add('iat', "$createTime")
    $payload.Add('nbf', "$createTime")

    $header = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new()

    # Generate signed Client Identifier
    try {
        $rsaParams = Get-RsaParameters -privateKey $privateKey
        $encoder = Get-RS256JWTEncoder -rsaParams $rsaParams

        $script:signedClientIdentifier = $encoder.Encode($header, $payload, [System.Byte[]]::new(0))
    }
    catch {
        Write-Error $_.Exception | format-list -force
        Break
    }
}

function Get-SignedAccessToken {
    param(
        [Parameter(Mandatory = $true)]
        $certificate,
        [Parameter(Mandatory = $false)]
        [System.Int32]$validForSeconds = 1800
    )

    $privateKey = "-----BEGIN PRIVATE KEY-----`n" + $certificate.Private + "`n-----END PRIVATE KEY-----"
    $publicKey = $certificate.Public

    $createTime = [System.Int32][System.Double]::parse((Get-Date -Date $((Get-Date).ToUniversalTime()) -UFormat %s))
    $expiryTime = [System.Int32][System.Double]::parse((Get-Date -Date $((Get-Date).addseconds($validforSeconds).ToUniversalTime()) -UFormat %s))

    # Build body for access token request
    $body = [ordered]@{
        clientIdentifierJwt = "$signedClientIdentifier"
        base64EncodedStringPublicKey = "$publicKey"
        #validityForAccessTokenInMinutes = 30
    }

    # Convert body to json format
    $requestObject = $body | ConvertTo-Json

    $requestbody = @"
$requestObject
"@

    # Set access token request headers
    $headers = @{'Content-type' = 'application/json' }

    # If JWT has been generated, request access token
    if ($body.clientIdentifierJwt) {
        $accessToken = (Invoke-RestMethod -Method Post -Uri "https://eu-kcs-api.samsungknox.com/ams/v1/users/accesstoken" -Body $requestBody -Headers $headers).accessToken
    }
    else {
        Write-Error "Not able to get JWT"
    }

    # If access token request failed, break
    if (-not ($accesstoken)) {
        Write-Error "Not able to get access token"
        Break
    }
    else {
        $payload = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new()
        $payload.Add('accessToken', "$accessToken")
        $payload.Add('publicKey', "$publicKey")
        $payload.Add('jti', "$([guid]::NewGuid())")
        $payload.Add('aud', "KnoxWSM")       
        $payload.Add('exp', "$expiryTime")
        $payload.Add('iss', "Knox")
        $payload.Add('iat', "$createTime")
        $payload.Add('nbf', "$createTime")

        $header = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new()

        try {
            $rsaParams = Get-RsaParameters $privateKey
            $encoder = Get-RS256JWTEncoder $rsaParams

            $script:signedAccessToken = $encoder.Encode($header, $payload, [System.Byte[]]::new(0))
        }
        catch {
            Write-Error $_.Exception | format-list -force
            Break
        }
    }
}
#endregion functions

$ErrorActionPreference = "Stop"

# Write to the Azure Functions log stream.
Write-Host "PowerShell HTTP trigger function processed a request."

# Interact with query parameters or the body of the request.
$keysJson = $Request.Query.keysJson.Replace
if (-not $keysJson) {
    $keysJson = $Request.Body.keysJson
}
$clientId = $Request.Query.clientId
if (-not $clientId) {
    $clientId = $Request.Body.clientId
}

$certificate = $keysJson | ConvertFrom-Json

try
{
    # Generate access token
    Get-SignedClientIdentifier -certificate $certificate -clientId $clientId
    Get-SignedAccessToken -certificate $certificate

    $Body = $signedAccessToken
    $StatusCode = [HttpStatusCode]::OK
}
catch
{
   $Body = $_.Exception.Message
   $StatusCode = [HttpStatusCode]::InternalServerError
}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = $StatusCode
    Body = $Body
})

Logic Apps

updateknoxtoken-logicapps

Microsoft Graph API

Note: See the blog post from Daniel Chronlund that I have linked below how to get started with the Microsoft Graph API to replace the profile token in MEM

HTTP – Get Intune Graph token

The HTTP methodPOST
The request URLhttps://login.microsoftonline.com/tenantId (Initialize variable)/oauth2/token
The Content-Type headerapplication/x-www-form-urlencoded
The request bodygrant_type=password&client_id=intuneClientId (Initialize variable)&client_secret=urlEncodedIntuneClientSecret (Compose)&username=intuneUsername (Initialize variable)&password=urlEncodedIntunePassword (Compose)&resource=https://graph.microsoft.com

Update 2022-01-04: Now it works with application permissions. Below I changed the step to get a Intune Graph token

The HTTP methodPOST
The request URLhttps://login.microsoftonline.com/tenantId (Initialize variable)/oauth2/v2.0/token
The Content-Type headerapplication/x-www-form-urlencoded
The request bodygrant_type=client_credentials&client_id=intuneClientId (Initialize variable)&client_secret=urlEncodedIntuneClientSecret (Compose)&scope=https://graph.microsoft.com/.default

HTTP – Get Intune Android dedicated profile

The HTTP methodGET
The request URLhttps://graph.microsoft.com/beta/deviceManagement/androidDeviceOwnerEnrollmentProfiles?$filter=displayName eq ‘intuneProfileDisplayName (Initialize variable)‘&select=id,displayName
The Authorization headerBearer Parse JSON – Parse Intune Graph token response body (body)?[‘access_token’]

Compose – intuneProfileId

first(body('Parse_JSON_-_Parse_Intune_Android_dedicated_profile_response_body')?['value'])?['id']

HTTP – Replace Intune Android dedicated profile token

The HTTP methodPOST
The request URLhttps://graph.microsoft.com/beta/deviceManagement/androidDeviceOwnerEnrollmentProfiles/intuneProfileId (Compose)/createToken
The Content-Type headerapplication/x-www-form-urlencoded
The request body{
“tokenValidityInSeconds”: 7776000
}

HTTP – Get Intune Android dedicated profile token

The HTTP methodGET
The request URLhttps://graph.microsoft.com/beta/deviceManagement/androidDeviceOwnerEnrollmentProfiles/intuneProfileId (Initialize variable)/?select=tokenValue
The Authorization headerBearer Parse JSON – Parse Intune Graph token response body (body)?[‘access_token’]

Samsung KME API

GetKnoxJWTAccessToken

The request body{
“clientId”: “knoxClientId (Get secret)“,
“keysJson”: “knoxKeysJson (Get secret)
}

HTTP – Get Knox profile

The HTTP methodGET
The request URLhttps://eu-kcs-api.samsungknox.com/kcs/v1/kme/profiles/list/?search=knoxProfileName (Initialize variable)
The cache-control headerno-cache
The charset headerUTF-8
The content-type headerapplication/json
The x-knox-apitoken headerGetKnoxJWTAccessToken (body)

Compose – Update token value in mdmProfileCustomData

replace(first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['mdmProfileCustomData'], json(first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['mdmProfileCustomData'])?['com.google.android.apps.work.clouddpc.EXTRA_ENROLLMENT_TOKEN'], body('Parse_JSON_-_Parse_Intune_Android_dedicated_profile_token_response_body_')?['tokenValue'])

Compose – Update Intune token for Knox profile JSON body

{
  "customerId": "@{variables('knoxCustomerId')}",
  "mdmApkInfo": @{first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['mdmApkInfo']},
  "mdmProfileCustomData": @{outputs('Compose_-_Update_token_value_in_mdmProfileCustomData')},
  "profileId": "@{first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['id']}",
  "profileName": "@{first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['name']}",
  "supportCompanyName": "@{first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['supportInfo']?['companyName']}",
  "supportEmailAddress": "@{first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['supportInfo']?['supportEmailAddress']}",
  "supportPhoneNumber": "@{first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['supportInfo']?['supportPhoneNumber']}",
  "supportedMDM": "@{first(body('Parse_JSON_-_Get_Knox_profile')?['profileList'])?['supportedMDM']}"
}

HTTP – Update Intune token for Knox profile

The HTTP methodPUT
The request URLhttps://eu-kcs-api.samsungknox.com/kcs/v1/kme/profiles/first(Parse JSON – Get Knox profile (body)?[‘profileList’])?[‘id’]
The cache-control headerno-cache
The charset headerUTF-8
The content-type headerapplication/json
The x-knox-apitoken headerGetKnoxJWTAccessToken (body)
The request bodyUpdate Intune token for Knox profile JSON body (Compose)

Summary

As I said before, some of the code is based from other people’s examples so that I could quickly get a workable solution. Will change and improve the code when I get more time but I hope this can help others to get started. You could also build this entirely in PowerShell and instead use Azure Automation to run the automation. I also hope that Microsoft solves the problem of not being able to use the Graph application permissions to replace the profile token in MEM as Daniel Chronlund mentions in his blog post that I have linked below.

Thanks and inspiration

Tobias Almen – Get devices from Samsung Knox Mobile Enrollment using Powershell (almenscorner.io)

Nicola Suter – Add PowerShell modules to Azure functions – nicolonsky tech

Daniel Chronlund – How to Automate Renewal of Android Dedicated Devices Enrollment Tokens and QR Codes in MEM (Solve the 90 Day Limit Issue) – Daniel Chronlund Cloud Tech Blog