MSStore.psm1

################################
# Start: Internal use functions
################################

function New-CV() {
  $cv = [Convert]::ToBase64String([Guid]::NewGuid().ToByteArray(), 0, 12)

  $cv
}

function Get-AccessTokenFromSessionData() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [System.Management.Automation.SessionState]
    $SessionState
  )
    
  $connectionInfo = Get-MSStoreConnectionInfo -SessionState $SessionState

  $token = Get-AccessToken -ConnectionInfo $connectionInfo
    
  $token
}

function Get-AccessToken() {
  param(
    [Parameter(Mandatory = $true)]
    [PSCustomObject]$ConnectionInfo
  )


  $authCtx = $ConnectionInfo.AuthCtx
  $credentials = $ConnectionInfo.Credentials
  $clientId = $ConnectionInfo.ClientId
  $resource = $ConnectionInfo.Resource

  $userCredential = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.UserPasswordCredential"($credentials.Username, $credentials.Password)

  $token = [Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContextIntegratedAuthExtensions]::AcquireTokenAsync(
    $AuthCtx,
    $Resource, 
    $ClientId, 
    $userCredential).Result
        
  $token 
}

function Get-MSStoreConnectionInfo {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [System.Management.Automation.SessionState]
    $SessionState
  )
    
  if ($sessionState.PSVariable -eq $null) {
    throw "unable to access SessionState.PSVariable, Please call Connect-MSStore before calling any other Powershell CmdLet for the MSStore Module"
  }

  $connectionInfo = $sessionState.PSVariable.GetValue("ConnectionInfo");

  if ($connectionInfo -eq $null) {
    throw "Please call Connect-MSStore before calling any other Powershell CmdLet for the WSfB Management Tools Service"
  }

  return $connectionInfo
}

function Get-MSStoreBaseUri() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [System.Management.Automation.SessionState]
    $SessionState
  )

  $connectionInfo = Get-MSStoreConnectionInfo -SessionState $SessionState

  $connectionInfo.MtsBaseUri

}

################################
# End: Internal use functions
################################


################################
# Start: Exported functions
################################

<#
    .SYNOPSIS
    Method to retrieve token for access to MSStore
#>

function Grant-MSStoreClientAppAccess() {
  param(
    [string]
    $ClientId = "295a96a4-53fa-41ee-9a49-91fb99f95a00",

    [Uri]
    $RedirectUri = [uri] "http://localhost/mts/tools",

    [string]
    $Resource = "https://onestore.microsoft.com"
  )

  $authorityUrl = "https://login.windows.net/common"
    
  $authCtx = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" $authorityUrl

  $platformParams = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.PlatformParameters" ([Microsoft.IdentityModel.Clients.ActiveDirectory.PromptBehavior]::Always)

  $token = $authCtx.AcquireTokenAsync($Resource, $ClientId, $RedirectUri, $platformParams).Result

  if ($token -eq $null) {
    Write-Error "Unable to properly authorize the client application $($ClientId)"
  }
}

<#
    .SYNOPSIS
    Method to connect to MSStore with the credentials specified
#>

function Connect-MSStore() {
  [CmdletBinding()]
  param(
    # Parameter help description
    [Parameter(Mandatory = $true)]
    [pscredential]
    $Credentials,

    [string]
    $ClientId = "295a96a4-53fa-41ee-9a49-91fb99f95a00",

    [Uri]
    $RedirectUri = [uri] "http://localhost/mts/tools",

    [string]
    $Resource = "https://onestore.microsoft.com",

    [string]
    $MtsBaseUri = "https://bspmts.mp.microsoft.com"
  )

  $authorityUrl = "https://login.windows.net/common"
  $authCtx = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" $authorityUrl

  $connectionInfo = [PSCustomObject]@{
    AuthCtx     = $authCtx
    Credentials = $Credentials
    Resource    = $Resource 
    ClientId    = $ClientId
    MtsBaseUri  = $MtsBaseUri.TrimEnd("/") # no trailing slashes allowed
  }

  $token = Get-AccessToken -ConnectionInfo $connectionInfo

  if ($token -eq $null) {
    throw "Unable to retrieve token for user '$($Credentials.Username)', ensure you've allowed access to the client application by calling Grant-MSStoreClientAppAccess"
  }


  $sessionState = $PSCmdlet.SessionState

  $sessionState.PSVariable.Set("ConnectionInfo", $connectionInfo)
}

<#
    .SYNOPSIS
    Method to retrieve applications from tenant's inventory
#>

function Get-MSStoreInventory() {
  [CmdletBinding()]
  param(
    [string] $ContinuationToken,
    [switch] $ExcludeOnline,
    [switch] $ExcludeOffline,
    [int] $MaxResults = 25,
    [System.Nullable[DateTime]]$ModifiedSince = $null 
  )

  $token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  $cv = New-CV
  $mtsBaseUri = Get-MSStoreBaseUri -SessionState $PSCmdlet.SessionState
  $mDollarBaseUri = "https://displaycatalog.mp.microsoft.com"

  if ($ExcludeOnline -and $ExcludeOffline) {
    throw "Cannot exclude both online and offline from the inventory query"
  }

  $queryParameters = ""

  if (-not $ExcludeOnline) {
    $queryParameters += "licenseTypes=Online&"
  }

  if (-not $ExcludeOffline) {
    $queryParameters += "licenseTypes=Offline&"
  }

  if (-not [String]::IsNullOrWhiteSpace($ContinuationToken)) {
    $queryParameters += "continuationtoken=$($ContinuationToken)&"
  }

  if ($MaxResults -ne $null) {
    $queryParameters += "maxResults=$($MaxResults)&"
  }

  if ($ModifiedSince -ne $null) {
    $queryParameters += "modifiedSince=$($ModifiedSince.Value.ToString("O"))&"
  }

  $queryParameters = $queryParameters.TrimEnd("&");
  $queryParameters += "&IncludeRemoved=false&includeSubscription=true"

  $restPath = "$mtsBaseUri/V1/Inventory?$queryParameters"
  $response = Invoke-RestMethod `
    -Method GET `
    -Uri $restPath `
    -Headers @{
    "MS-CV"         = $cv
    "Authorization" = "Bearer $($token.AccessToken)"
  }
  $productDictionary = @{}

  $productList = @($response.inventoryEntries | % {$_.productKey.productId}) -join ","

  $mDollarQueryParameters = Get-QueryString ([ordered]@{
      bigIds         = $productList
      market         = "US"
      languages      = "en-us"
      catalogId      = "4"
      fieldsTemplate = "Details"
    })

  $mDollarRestPath = "$mDollarBaseUri/v7.0/products?$mDollarQueryParameters"
  $mDollarContinuationToken = $null
    

  do {
    $mDollarresponse = Invoke-RestMethod `
      -Method GET `
      -Uri $mDollarRestPath `
      -Headers @{
      "MS-CV"         = $cv
      "Authorization" = "Bearer $($token.AccessToken)"
    }
    foreach ($product in $mDollarresponse.products) {
      $productDictionary.Add($product.productId, $product.LocalizedProperties.ProductTitle)
    }
    $mDollarContinuationToken = $result.ContinuationToken
  }while (-not ([String]::IsNullOrWhiteSpace($mDollarContinuationToken)))

  foreach ($inventoryEntry in $response.inventoryEntries) {    
    $inventoryEntry | Add-Member -type NoteProperty -name ProductTitle -value $productDictionary[$inventoryEntry.productKey.productId]
    $inventoryEntry | Add-Member -type NoteProperty -name ProductId -value $inventoryEntry.productKey.productId
    $inventoryEntry | Add-Member -type NoteProperty -name SkuId -value $inventoryEntry.productKey.skuId
    $inventoryEntry.PSObject.Properties.Remove('productKey')
    $inventoryEntry.PSObject.Properties.Remove('lastModified')
    $inventoryEntry.PSObject.Properties.Remove('status')
    $inventoryEntry.PSObject.Properties.Remove('distributionPolicy')
  }
  $response.inventoryEntries
}

function Get-QueryString {
  param(
    [System.Collections.Specialized.OrderedDictionary]$Parameters
  )

  if ($Parameters) {
    @($Parameters.GetEnumerator() | ForEach-Object { $_.Name + '=' + $_.Value }) -join '&'
  }
}


function Get-MSStoreSeatAssignments() {
  [CmdletBinding(DefaultParameterSetName = "Batch")]
  param(
    [Parameter(Mandatory = $true)]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [string] $SkuId, 
    [Parameter(ParameterSetName = "Batch")]
    [ValidateRange(1, 25)]
    [int]$PageSize = 25
  )

  $continuationToken = $null    
    
  do {
    $result = Get-MtsSeatAssignmentsInternal `
      -ProductId $ProductId `
      -SkuId $SkuId `
      -MaxPageSize $PageSize `
      -ContinuationToken $continuationToken `
      -SessionState $PSCmdlet.SessionState

    Write-Output $result.Seats

    $continuationToken = $result.ContinuationToken
  }
  while (-not ([String]::IsNullOrWhiteSpace($continuationToken)))
}


<#
    .SYNOPSIS
    Method to retrieve Seat Assignment details
#>

function Get-MSStoreSeatAssignmentsInternal() {
  [CmdletBinding(DefaultParameterSetName = "Base")]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId ,
    [Parameter(ParameterSetName = "Base")]
    [string] $ContinuationToken,
    [Parameter(ParameterSetName = "Base")]
    [int] $MaxPageSize = 25
  )

  Get-MtsSeatAssignmentsInternal `
    -ProductId $ProductId `
    -SkuId $SkuId `
    -ContinuationToken $ContinuationToken `
    -MaxPageSize $MaxPageSize `
    -SessionState $PSCmdlet.SessionState     
}

function Get-MtsSeatAssignmentsInternal() {
  [CmdletBinding(DefaultParameterSetName = "Base")]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId ,
    [Parameter(ParameterSetName = "Base")]
    [string] $ContinuationToken,
    [Parameter(ParameterSetName = "Base")]
    [int] $MaxPageSize = 25,
    [Parameter(Mandatory = $true)]
    [System.Management.Automation.SessionState]
    $SessionState

  )

  $token = Get-AccessTokenFromSessionData -SessionState $SessionState
  $cv = New-CV
  $mtsBaseUri = Get-MSStoreBaseUri -SessionState $SessionState

  $queryParameters = ""

  if (-not [String]::IsNullOrWhiteSpace($ContinuationToken)) {
    $queryParameters += "continuationtoken=$($ContinuationToken)&"
  }

  if ($MaxPageSize -ne $null) {
    $queryParameters += "maxResults=$($MaxPageSize)&"
  }

  # get rid of any trailing ampersands
  $queryParameters = $queryParameters.TrimEnd("&");

  $restPath = "$mtsBaseUri/V1/Inventory/$($ProductId)/$($SkuId)/Seats?$($queryParameters)"
    
  $response = Invoke-RestMethod `
    -Method GET `
    -Uri $restPath `
    -Headers @{
    "MS-CV"         = $cv
    "Authorization" = "Bearer $($token.AccessToken)"
  } 
            
  $response
}


<#
    .SYNOPSIS
    Method to assign seats to a user
#>

function Add-MSStoreSeatAssignment() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $Username,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId 
  )

  $token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  $cv = New-CV
  $mtsBaseUri = Get-MSStoreBaseUri -SessionState $PSCmdlet.SessionState
    
  $restPath = "$mtsBaseUri/V1/Inventory/$($ProductId)/$($SkuId)/Seats/$($Username)"
  $response = Invoke-RestMethod `
    -Method Post `
    -Uri $restPath `
    -Headers @{
    "MS-CV"         = $cv
    "Authorization" = "Bearer $($token.AccessToken)"
  } `
    -ContentType 'application/json'
            
  $response    

  Get-StoreInstallLink $ProductId $SkuId
}

<#
    .SYNOPSIS
    Method to remove seats assignments
#>

function Remove-MSStoreSeatAssignments() {
  [CmdletBinding(DefaultParameterSetName = "Usernames")]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId,
    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 25)]
    [int] $BatchSize = 25,
    [Parameter(Mandatory = $true, ParameterSetName = "Usernames")]
    [string[]] $Usernames,
    [Parameter(Mandatory = $true, ParameterSetName = "Csv")]
    [ValidateNotNullOrEmpty()]
    [string] $PathToCsv,
    [Parameter(Mandatory = $false, ParameterSetName = "Csv")]
    [ValidateNotNullOrEmpty()]
    [string] $ColumnName = "Username",
    [switch] $ShowProgress

  )

  if ($PSCmdlet.ParameterSetName -eq "Usernames") {
    Start-MtsBulkSeatOperation `
      -Operation "reclaim" `
      -ProductId $ProductId `
      -SkuId $SkuId `
      -BatchSize $BatchSize `
      -Usernames $Usernames `
      -ShowProgress:$ShowProgress `
      -SessionState $PSCmdlet.SessionState
  }
  elseif ($PSCmdlet.ParameterSetName -eq "Csv") {
    Start-MtsBulkSeatOperation `
      -Operation "reclaim" `
      -ProductId $ProductId `
      -SkuId $SkuId `
      -BatchSize $BatchSize `
      -PathToCsv $PathToCsv `
      -ColumnName $ColumnName `
      -ShowProgress:$ShowProgress `
      -SessionState $PSCmdlet.SessionState
  }

}

<#
    .SYNOPSIS
    Method to assign seats to a user
#>

function Add-MSStoreSeatAssignments() {
  [CmdletBinding(DefaultParameterSetName = "Usernames")]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId,
    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 25)]
    [int] $BatchSize = 25,
    [Parameter(Mandatory = $true, ParameterSetName = "Usernames")]
    [string[]] $Usernames,
    [Parameter(Mandatory = $true, ParameterSetName = "Csv")]
    [ValidateNotNullOrEmpty()]
    [string] $PathToCsv,
    [Parameter(Mandatory = $false, ParameterSetName = "Csv")]
    [ValidateNotNullOrEmpty()]
    [string] $ColumnName = "Username",
    [switch] $ShowProgress

  )

  if ($PSCmdlet.ParameterSetName -eq "Usernames") {
    Start-MtsBulkSeatOperation `
      -Operation "assign" `
      -ProductId $ProductId `
      -SkuId $SkuId `
      -BatchSize $BatchSize `
      -Usernames $Usernames `
      -ShowProgress:$ShowProgress `
      -SessionState $PSCmdlet.SessionState
  }
  elseif ($PSCmdlet.ParameterSetName -eq "Csv") {
    Start-MtsBulkSeatOperation `
      -Operation "assign" `
      -ProductId $ProductId `
      -SkuId $SkuId `
      -BatchSize $BatchSize `
      -PathToCsv $PathToCsv `
      -ColumnName $ColumnName `
      -ShowProgress:$ShowProgress `
      -SessionState $PSCmdlet.SessionState
  }

  Get-StoreInstallLink $ProductId $SkuId
}

function Start-MtsBulkSeatOperation() {
  [CmdletBinding(DefaultParameterSetName = "Usernames")]
  param(
    [Parameter(Mandatory = $true)]
    [string] $Operation,
    [Parameter(Mandatory = $true)]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [string] $SkuId,
    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 25)]
    [int] $BatchSize = 25,
    [Parameter(Mandatory = $true, ParameterSetName = "Usernames")]
    [string[]] $Usernames,
    [Parameter(Mandatory = $true, ParameterSetName = "Csv")]
    [ValidateNotNullOrEmpty()]
    [string] $PathToCsv,
    [Parameter(Mandatory = $false, ParameterSetName = "Csv")]
    [ValidateNotNullOrEmpty()]
    [string] $ColumnName = "Username",
    [switch] $ShowProgress,
    [Parameter(Mandatory = $true)]
    [System.Management.Automation.SessionState]
    $SessionState

  )

  process { 
    $_enforceSingleCall = $false

    $usernamesToProcess = $null

    if ($PSCmdlet.ParameterSetName -eq "Usernames") {
      if ($Usernames -eq $null -or $Usernames.Length -eq 0) {
        throw "At least one username must be specified in the Usernames parameter"
      }
            
      if ($_enforceSingleCall -and $Usernames.Length -gt 25) {
        throw "The maximum number of assignments in one call is 25."
      }
            

      $usernamesToProcess = [string[]] ($Usernames | ? {-not [String]::IsNullOrWhiteSpace($_)})
    }
    elseif ($PSCmdlet.ParameterSetName -eq "Csv") {
      $inputData = Import-Csv -Path $PathToCsv

      $usernamesToProcess = [string[]] ($inputData | % { $_.$ColumnName})


      # read the Csv

      # take only entries which have a "Username" column in them
    }
       

    $processedItemCount = 0;

    while ($processedItemCount -lt $usernamesToProcess.Length) {
      if ($ShowProgress) {
        Write-Progress  -Activity "Bulk Operation" -Status "Percent complete:"  -PercentComplete (($processedItemCount / $usernamesToProcess.Length) * 100)
      }

      $currentBatch = [string[]]($usernamesToProcess | select-object -Skip $processedItemCount -First $BatchSize)

      # get a new token on each "batch" to ensure it's not expired
      $token = Get-AccessTokenFromSessionData -SessionState $SessionState

      $cv = New-CV

      $mtsBaseUri = Get-MSStoreBaseUri -SessionState $SessionState

      $body = @{
        usernames  = $currentBatch
        seatAction = $Operation
      }
            
      $restPath = "$mtsBaseUri/V1/Inventory/$($ProductId)/$($SkuId)/Seats"

      $response = Invoke-RestMethod `
        -Method Post `
        -Uri $restPath `
        -Headers @{
        "MS-CV"         = $cv
        "Authorization" = "Bearer $($token.AccessToken)"
      } `
        -Body ($body | ConvertTo-Json) `
        -ContentType 'application/json'

      # process bulk response into individual items
      $successfulAssignments = [object[]]$response.SeatDetails
      $failedAssignments = [object[]]$response.FailedSeatOperations

      if ($successfulAssignments -ne $null) {
        foreach ($successfulAssignment in $successfulAssignments) {
          Write-Output (New-Object psobject($successfulAssignment) -Property @{
              Result = "Succeeded"
            })
        }
      }

      if ($failedAssignments -ne $null) {
        foreach ($failedAssignment in $failedAssignments) {
          Write-Output (New-Object psobject($failedAssignment) -Property @{
              Result = "Failed"
            })
        }
      }

      $processedItemCount += $currentBatch.Length  

      if ($ShowProgress) {
        Write-Progress  -Activity "Bulk Operation" -Status "Percent complete:"  -PercentComplete (($processedItemCount / $usernamesToProcess.Length) * 100)
      }
    }
  }  
}


function Remove-MSStoreSeatAssignmentsLegacy() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [ValidateCount(1, 25)]
    [string[]] $Usernames,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId 
  )

  $token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  $cv = New-CV
  $mtsBaseUri = Get-MSStoreBaseUri -SessionState $PSCmdlet.SessionState

  $body = @{
    usernames  = $Usernames
    seatAction = "reclaim"
  }
    
  $restPath = "$mtsBaseUri/V1/Inventory/$($ProductId)/$($SkuId)/Seats"
  $response = Invoke-RestMethod `
    -Method Post `
    -Uri $restPath `
    -Headers @{
    "MS-CV"         = $cv
    "Authorization" = "Bearer $($token.AccessToken)"
  } `
    -Body ($body | ConvertTo-Json) `
    -ContentType 'application/json'
            
  $response
}

<#
    .SYNOPSIS
    Method to remove seats assigned to a user
#>

function Remove-MSStoreSeatAssignment() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $Username,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId 
  )

  $token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  $cv = New-CV
  $mtsBaseUri = Get-MSStoreBaseUri -SessionState $PSCmdlet.SessionState
    
  $restPath = "$mtsBaseUri/V1/Inventory/$($ProductId)/$($SkuId)/Seats/$($Username)"

  $response = Invoke-RestMethod `
    -Method Delete `
    -Uri $restPath `
    -Headers @{
    "MS-CV"         = $cv
    "Authorization" = "Bearer $($token.AccessToken)"
  } 

  $response 
}

<#
    .SYNOPSIS
    Method to retrieve install link for given product and sku
#>

function Get-StoreInstallLink() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $SkuId 
  )
  write-host "You can provide this URL to your users in order to install this app from the store:"

  $installLink = "https://businessstore.microsoft.com/en-us/AppInstall?productId=" + $ProductId + "&skuId=" + $SkuId + "&catalogId=4"
  write-host $installLink `n`n
}


################################
# End: Exported functions
################################

Write-Host "WSfB Management Tools Powershell module loaded"