MSCommerce.psm1

################################
# Start: Platform gate
# WAM broker authentication (introduced in MSCommerce 3.0 for ICM 796868403)
# requires Windows. Fail fast with a clear message on non-Windows PS Core hosts
# rather than letting a confusing MSAL/native exception surface later.
################################

if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) {
    throw "MSCommerce requires Windows. WAM broker authentication is unavailable on $($PSVersionTable.OS)."
}

################################
# End: Platform gate
################################

################################
# Start: P/Invoke helpers for WAM parent window
################################

if (-not ('MSCommerceNativeMethods' -as [type])) {
Add-Type -TypeDefinition @"
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
 
public static class MSCommerceNativeMethods {
    [DllImport("kernel32.dll")]
    public static extern IntPtr GetConsoleWindow();
 
    [DllImport("user32.dll", ExactSpelling = true)]
    public static extern IntPtr GetAncestor(IntPtr hwnd, uint flags);
 
    [DllImport("user32.dll")]
    public static extern IntPtr GetForegroundWindow();
 
    /// <summary>
    /// Returns a window handle suitable for WAM's modal authentication dialog.
    /// Tries three sources in order: console window (works in conhost/cmd),
    /// current process main window, and foreground window (works in Windows Terminal).
    /// </summary>
    public static IntPtr GetConsoleOrTerminalWindow() {
        IntPtr hwnd = GetConsoleWindow();
        if (hwnd != IntPtr.Zero)
            return GetAncestor(hwnd, 3 /* GetRootOwner */);
 
        hwnd = Process.GetCurrentProcess().MainWindowHandle;
        if (hwnd != IntPtr.Zero)
            return hwnd;
 
        return GetForegroundWindow();
    }
}
"@

}

################################
# End: P/Invoke helpers
################################

################################
# Start: Module-scope caches
# ICM 796868403: A singleton PublicClientApplication per ClientId is required.
# Building a fresh PCA on every Connect-MSCommerce call put the WAM broker into
# a bad state on the second invocation (Tag 0x1e3d43d2 / unknown_broker_error).
# The Microsoft Learn WAM reference sample also builds exactly one PCA and
# reuses it across acquisitions. The IAccount cache lets us prime
# AcquireTokenSilent with the exact account from the prior successful sign-in,
# avoiding the multi-account heuristic the original M-1 refinement used. The
# AuthResult cache short-circuits repeated Connect-MSCommerce calls when the
# previously issued access token is still valid (true no-op idempotency: avoids
# touching MSAL/WAM at all on the second call).
#
# Cache keys are $ClientId only. If future changes add tenant/authority/cloud
# options to the PCA builder, the key must be expanded to include them.
################################

$script:PCACache = @{}
$script:AccountCache = @{}
$script:AuthResultCache = @{}

################################
# End: Module-scope caches
################################

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

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

  if ($null -eq $SessionState.PSVariable) {
    throw "unable to access SessionState. PSVariable, Please call Connect-MSCommerce before calling any other Powershell CmdLet for the MSCommerce Module"
  }

  $token = $SessionState.PSVariable.GetValue("token");

  if ($null -eq $token) {
    throw "You must call the Connect-MSCommerce cmdlet before calling any other cmdlets"
  }

  return $token
}

function HandleError() {
  param(
    [Parameter(Mandatory = $true)]
    $ErrorContext,

    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $CustomErrorMessage
  )

  $errorMessage = $ErrorContext.Exception.Message
  $errorDetails = $ErrorContext.ErrorDetails.Message

  if ($_.Exception.Response.StatusCode -eq 401) {
    Write-Error "Your credentials have expired. Please, call Connect-MSCommerce again to regain access to MSCommerce Module."

    return
  }

  write-error "$CustomErrorMessage, ErrorMessage - $errorMessage ErrorDetails - $errorDetails"
}

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


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

<#
    .SYNOPSIS
    Method to connect to MSCommerce with the credentials specified.
    Uses WAM (Web Account Manager) broker for secure authentication,
    eliminating the SR13 authorization-code phishing vulnerability
    from the previous loopback redirect URI flow (ICM 796868403).
#>

function Connect-MSCommerce() {
  [CmdletBinding()]
  param(
    [string]
    $ClientId = "3d5cffa9-04da-4657-8cab-c7f074657cad",

    [string]
    $Resource = "aeb86249-8ea3-49e2-900b-54cc8e308f85/.default"   #LicenseManager App Id
  )

  # Build or reuse the singleton PublicClientApplication for this ClientId.
  # See ICM 796868403 PCA-reuse spec doc — per-call PCA construction collides
  # with the native broker channel lifecycle and causes the second
  # Connect-MSCommerce in a session to fail with Tag 0x1e3d43d2.
  # The WithParentActivityOrWindow Func is re-evaluated by MSAL on each
  # AcquireToken call, so the cached PCA still picks up the current top-level
  # window even if the user switched terminal windows since the last call.
  if (-not $script:PCACache.ContainsKey($ClientId)) {
    $brokerOptions = [Microsoft.Identity.Client.BrokerOptions]::new(
        [Microsoft.Identity.Client.BrokerOptions+OperatingSystems]::Windows
    )

    $builder = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($ClientId)
    $builder = $builder.WithDefaultRedirectUri()
    $builder = $builder.WithParentActivityOrWindow([System.Func[System.IntPtr]] {
        [MSCommerceNativeMethods]::GetConsoleOrTerminalWindow()
    })
    $builder = [Microsoft.Identity.Client.Broker.BrokerExtension]::WithBroker($builder, $brokerOptions)
    $script:PCACache[$ClientId] = $builder.Build()
  }
  $clientApplication = $script:PCACache[$ClientId]

  $scopes = New-Object Collections.Generic.List[string]
  $scopes.Add($Resource)

  # Fast-path idempotency: if we have a cached AuthenticationResult for this
  # ClientId whose access token is still valid (with a 5-minute safety margin
  # to allow downstream API calls time to complete), reuse it without touching
  # MSAL or the WAM broker at all. This is the strongest form of idempotency
  # for repeated Connect-MSCommerce calls (user requirement #4) and serves as
  # a safety net if the WAM "second broker operation" bug isn't fully resolved
  # by PCA reuse alone.
  $cachedAuth = $script:AuthResultCache[$ClientId]
  if ($null -ne $cachedAuth -and $cachedAuth.ExpiresOn -gt [DateTimeOffset]::UtcNow.AddMinutes(5)) {
    $sessionState = $PSCmdlet.SessionState
    $sessionState.PSVariable.Set("token", $cachedAuth.AccessToken)
    Write-Verbose "Reusing cached access token (expires $($cachedAuth.ExpiresOn.UtcDateTime.ToString('o')))"
    Write-Host "Connection established successfully"
    return
  }

  # Silent-first acquisition (Microsoft Learn WAM reference pattern).
  # We only attempt silent acquisition when $script:AccountCache has an
  # IAccount from a prior successful sign-in. On a cold cache (first Connect
  # after Import-Module), skip silent and go straight to interactive — this
  # preserves the explicit account-picker UX customers expect on first call
  # and avoids silently authenticating as an arbitrary cached WAM account.
  # Only MsalUiRequiredException triggers the interactive fallback; any other
  # exception bubbles to the outer try/catch.
  $authenticationResult = $null
  try {
    $cachedAccount = $script:AccountCache[$ClientId]
    if ($null -ne $cachedAccount) {
      try {
        $authenticationResult = $clientApplication.AcquireTokenSilent($scopes, $cachedAccount).
            ExecuteAsync().GetAwaiter().GetResult()
      } catch [Microsoft.Identity.Client.MsalUiRequiredException] {
        Write-Verbose "Silent token acquisition requires UI: $($_.Exception.Message)"
        # Clear the stale account so a successful interactive sign-in below
        # can replace it with the fresh IAccount the user actually picked.
        $script:AccountCache.Remove($ClientId)
      }
    }

    if ($null -eq $authenticationResult) {
      $authenticationResult = $clientApplication.AcquireTokenInteractive($scopes).
          ExecuteAsync().GetAwaiter().GetResult()
    }
  } catch {
    Write-Error "Unable to establish connection: $($_.Exception.Message)"
    return
  }

  $token = $authenticationResult.AccessToken

  if ($null -eq $token) {
    Write-Error "Unable to establish connection"

    return
  }

  # Cache the account and full AuthenticationResult for the next call:
  # AccountCache primes the silent path; AuthResultCache enables the fast-path
  # above when the token is still within its lifetime.
  $script:AccountCache[$ClientId] = $authenticationResult.Account
  $script:AuthResultCache[$ClientId] = $authenticationResult

  $sessionState = $PSCmdlet.SessionState

  $sessionState.PSVariable.Set("token", $token)

  Write-Host "Connection established successfully"
}

<#
    .SYNOPSIS
    Method to retrieve configurable policies
#>

function Get-MSCommercePolicies() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [string] $Token
  )

  if (!$Token) {
    $Token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  }
  $correlationId = New-Guid
  $baseUri = "https://licensing.m365.microsoft.com"

  $restPath = "$baseUri/v1.0/policies"

  try {
    $response = Invoke-RestMethod `
      -Method GET `
      -Uri $restPath `
      -Headers @{
        "x-ms-correlation-id" = $correlationId
        "Authorization" = "Bearer $($Token)"
      }

    foreach ($policy in $response.items) {
      New-Object PSObject -Property @{
        PolicyId = $policy.id
        Description = $policy.description
        DefaultValue = $policy.defaultValue
      }
    }
  } catch {
    HandleError -ErrorContext $_ -CustomErrorMessage "Failed to retrieve policies"
  }
}

<#
    .SYNOPSIS
    Method to retrieve a description of the specified policy
#>

function Get-MSCommercePolicy() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $PolicyId,
    [Parameter(Mandatory = $false)]
    [string] $Token
  )

  if (!$Token) {
    $Token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  }

  $correlationId = New-Guid
  $baseUri = "https://licensing.m365.microsoft.com"

  $restPath = "$baseUri/v1.0/policies/$PolicyId"

  try {
    $response = Invoke-RestMethod `
      -Method GET `
      -Uri $restPath `
        -Headers @{
        "x-ms-correlation-id" = $correlationId
        "Authorization" = "Bearer $($Token)"
      }

    New-Object PSObject -Property @{
      PolicyId = $response.id
      Description = $response.description
      DefaultValue = $response.defaultValue
    }
  } catch {
    HandleError -ErrorContext $_ -CustomErrorMessage "Failed to retrieve policy with PolicyId '$PolicyId'"
  }
}

<#
    .SYNOPSIS
    Method to retrieve applicable products for the specified policy and their current settings
#>

function Get-MSCommerceProductPolicies() {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $PolicyId,
    [Parameter(Mandatory = $false)]
    [string] $Scope,
    [Parameter(Mandatory = $false)]
    [string] $Token
  )

  if (!$Token) {
    $Token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  }
  $correlationId = New-Guid
  $baseUri = "https://licensing.m365.microsoft.com"

  $query = "scope=product"
  if($false -eq [string]::IsNullOrWhiteSpace($Scope)){
    $query = "scope=$Scope"
  }
  
  $restPath = "$baseUri/v1.0/policies/$PolicyId/products"
  if($false -eq [string]::IsNullOrWhiteSpace($query)){
    $restPath += "?$query"
  }

  try {
    $response = Invoke-RestMethod `
      -Method GET `
      -Uri $restPath `
      -Headers @{
        "x-ms-correlation-id" = $correlationId
        "Authorization" = "Bearer $($Token)"
      }

    foreach ($product in $response.items) {
      $properties = @{}
      $properties.Add("PolicyId", $product.policyId)
      $properties.Add("PolicyValue", $product.policyValue)

      if ($product.scope -eq "product") {
        $properties.Add("ProductName", $product.productName)
        $properties.Add("ProductId", $product.productId)
      }
      else {
        $properties.Add("ScopeId", $product.scopeId)
        $properties.Add("ScopeValue", $product.scopeValue)
        $properties.Add("Scope", $product.scope)
      }
      New-Object PSObject -Property $properties
    }
  } catch {
    HandleError -ErrorContext $_ -CustomErrorMessage "Failed to retrieve product policy with PolicyId '$PolicyId'"
  }
}

<#
    .SYNOPSIS
    Method to retrieve the current setting for the policy for the specified product
#>

function Get-MSCommerceProductPolicy() {
  [CmdletBinding(DefaultParameterSetName = "Product")]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $PolicyId,
    [Parameter(Mandatory = $true, ParameterSetName = "Product")]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true, ParameterSetName = "OfferType")]
    [ValidateNotNullOrEmpty()]
    [string] $OfferType,
    [Parameter(Mandatory = $false)]
    [string] $Token
  )

  if (!$Token) {
    $Token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  }
  $correlationId = New-Guid
  $baseUri = "https://licensing.m365.microsoft.com"

  $restPath = "$baseUri/v1.0/policies/$PolicyId"
  if ($PSBoundParameters.ContainsKey("OfferType")) {
    $restPath += "/offerTypes/$OfferType"
  }
  else {
    $restPath += "/products/$ProductId"
  }

  try {
    $response = Invoke-RestMethod `
      -Method GET `
      -Uri $restPath `
      -Headers @{
        "x-ms-correlation-id" = $correlationId
        "Authorization" = "Bearer $($Token)"
      }

    $properties = @{}
    $properties.Add("PolicyId", $response.policyId)
    $properties.Add("PolicyValue", $response.policyValue)

    if ($PSBoundParameters.ContainsKey("ProductId")) {
      $properties.Add("ProductName", $response.productName)
      $properties.Add("ProductId", $response.productId)
    }
    else {
      $properties.Add("Scope", $response.scope)
      $properties.Add("ScopeValue", $response.scopeValue)
      $properties.Add("ScopeId", $response.scopeId)
    }
    New-Object PSObject -Property $properties
  } catch {
    HandleError -ErrorContext $_ -CustomErrorMessage "Failed to retrieve product policy with PolicyId '$PolicyId' ProductId '$ProductId'"
  }
}

<#
    .SYNOPSIS
    Method to modify the current setting for the policy for the specified product
#>

function Update-MSCommerceProductPolicy() {
  [CmdletBinding(SupportsShouldProcess, ConfirmImpact='Medium', DefaultParameterSetName = "ProductEnum")]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $PolicyId,
    [Parameter(Mandatory = $true, ParameterSetName = "ProductToggle")]
    [Parameter(Mandatory = $true, ParameterSetName = "ProductEnum")]
    [ValidateNotNullOrEmpty()]
    [string] $ProductId,
    [Parameter(Mandatory = $true, ParameterSetName = "OfferTypeToggle")]
    [Parameter(Mandatory = $true, ParameterSetName = "OfferTypeEnum")]
    [string] $OfferType,
    [Parameter(Mandatory = $true, ParameterSetName = "ProductToggle")]
    [Parameter(Mandatory = $true, ParameterSetName = "OfferTypeToggle")]
    [ValidateNotNullOrEmpty()]
    [string] $Enabled,
    [Parameter(Mandatory = $true, ParameterSetName = "ProductEnum")]
    [Parameter(Mandatory = $true, ParameterSetName = "OfferTypeEnum")]
    [ValidateSet("Enabled", "Disabled", "OnlyTrialsWithoutPaymentMethod")]
    [string] $Value,
    [Parameter(Mandatory = $false)]
    [string] $Token
  )

  if ($PSBoundParameters.ContainsKey("Enabled")) {
    if ("True" -ne $Enabled -and "False" -ne $Enabled) {
      Write-Error "Value of `$Enabled must be one of the following: `$True, `$true, `$False, `$false"
      return
    }
  }

  if (!$Token) {
    $Token = Get-AccessTokenFromSessionData -SessionState $PSCmdlet.SessionState
  }
  $correlationId = New-Guid
  $baseUri = "https://licensing.m365.microsoft.com"

  $restPath = "$baseUri/v1.0/policies/$PolicyId"
  if ($PSBoundParameters.ContainsKey("OfferType")) {
    $restPath += "/offerTypes/$OfferType"
  }
  else{
    $restPath += "/products/$ProductId"
  }

  if ($PSBoundParameters.ContainsKey("Enabled")) {
    $policyValue = if ("True" -eq $Enabled -or "true" -eq $Enabled) {"Enabled"} else {"Disabled"}
  }
  else {
    $policyValue = $Value
  }

  $body = @{
    policyValue = $policyValue
  }

  if ($False -eq $PSCmdlet.ShouldProcess("ShouldProcess?")) {
    Write-Host "Updating product policy aborted"

    return
  }

  try {
    $response = Invoke-RestMethod `
      -Method PUT `
      -Uri $restPath `
      -Body ($body | ConvertTo-Json)`
      -ContentType 'application/json' `
      -Headers @{
        "x-ms-correlation-id" = $correlationId
        "Authorization" = "Bearer $($Token)"
      }

    Write-Host "Update policy product success"
    $properties = @{}
    $properties.Add("PolicyId", $response.policyId)
    $properties.Add("PolicyValue", $response.policyValue)

    if ($PSBoundParameters.ContainsKey("ProductId")) {
      $properties.Add("ProductName", $response.productName)
      $properties.Add("ProductId", $response.productId)
    }
    else {
      $properties.Add("Scope", $response.scope)
      $properties.Add("ScopeValue", $response.scopeValue)
      $properties.Add("ScopeId", $response.scopeId)
    }
    return New-Object PSObject -Property $properties
  }
  catch {
    HandleError -ErrorContext $_ -CustomErrorMessage "Failed to update product policy"
  }
}

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

Write-Host "MSCommerce module loaded"