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" |