Private/Global-Functions.ps1
|
#Function to set the profile to beta function Set-GraphVersion { Write-Verbose "Setting profile as beta..." $script:MSGraphVersion = "beta" } #Function to check if the user is connected to Windows365 function Get-TokenValidity { if ($null -eq $script:Authtime) { Throw "No token found. Please authenticate first using the Connect-Windows365 command" } else { Write-Verbose "Token found. Checking validity..." $date = [System.DateTime]::UtcNow If ($date -gt $script:Authtime.AddMinutes(55)) { $script:Authtime = $null $script:Authtoken = $null $script:Authheader = $null Throw "Token expired. Please authenticate again using the Connect-Windows365 command" } else { Write-Verbose "Token is still valid." } } } function Get-AzureADGroupID { param ( [Parameter(Mandatory = $true)] [string]$GroupName ) $url = "https://graph.microsoft.com/$script:MSGraphVersion/groups?`$filter=displayName+eq+'$GroupName'" $result = Invoke-RestMethod -Uri $url -Headers $script:Authheader -Method GET if ($null -eq $result) { Write-Error "No groups returned" break } $script:GroupID = $result.value.id } function Invoke-APIRequest { param ( [Parameter()][string]$uri, [Parameter()][string]$Token, [Parameter()][string]$Method = 'Get' ) #Perform initial Graph Request $Headers = @{Authorization = "Bearer $($Token)" } $params = @{ uri = $uri Method = $Method Headers = $Headers } Write-Verbose "Request: $Method $uri" try { $result = Invoke-WebRequest @params -ErrorAction Stop } catch { Throw (Get-GraphErrorMessage $_) } #Check if the result is null if ($null -eq $result) { Throw "No response returned for $Method $uri" } $resultconvert = $result.Content | ConvertFrom-Json #Return single objects as-is; only collections have a value property if ($resultconvert.PSObject.Properties.Name -notcontains 'value') { return $resultconvert } $AllPages = @($resultconvert.value) #Loop through the API pages if there is a next link #The nextLink is opaque (https://learn.microsoft.com/graph/paging), so only check scheme and host before sending the token to it $NextLink = $resultconvert.'@odata.nextLink' $GraphHost = ([uri]$uri).Host $SeenLinks = [System.Collections.Generic.HashSet[string]]::new() $MaxPages = 1000 $PageCount = 1 while ($null -ne $NextLink) { $NextUri = $null if (-not [uri]::TryCreate([string]$NextLink, [System.UriKind]::Absolute, [ref]$NextUri) -or $NextUri.Scheme -ne 'https' -or $NextUri.Host -ne $GraphHost) { Throw "Refusing to follow @odata.nextLink '$NextLink': it must be an absolute https URL on $GraphHost" } if (-not $SeenLinks.Add([string]$NextLink)) { Throw "Graph returned the same @odata.nextLink twice, stopping to avoid an endless loop: $NextLink" } if ($PageCount -ge $MaxPages) { Throw "Stopped paging after $MaxPages pages for $uri" } $PageCount++ Write-Verbose "Requesting next page: $NextLink" try { $page = (Invoke-WebRequest -Uri $NextLink -Headers $Headers -Method Get -ErrorAction Stop).Content | ConvertFrom-Json } catch { Throw (Get-GraphErrorMessage $_) } $AllPages += $page.value $NextLink = $page.'@odata.nextLink' } return $AllPages } #Function to create a signed client assertion (JWT) for certificate based authentication #https://learn.microsoft.com/entra/identity-platform/certificate-credentials function New-ClientAssertion { param ( [Parameter(Mandatory = $true)] [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [Parameter(Mandatory = $true)] [string]$ClientID, [Parameter(Mandatory = $true)] [string]$TokenEndpoint ) $privateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) if ($null -eq $privateKey) { Throw "The certificate '$($Certificate.Subject)' has no accessible RSA private key. Use a certificate that includes its private key." } $toBase64Url = { param([byte[]]$Bytes) [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } $now = [DateTimeOffset]::UtcNow $header = @{ alg = 'RS256' typ = 'JWT' x5t = & $toBase64Url $Certificate.GetCertHash() } | ConvertTo-Json -Compress $claims = @{ aud = $TokenEndpoint iss = $ClientID sub = $ClientID jti = [guid]::NewGuid().ToString() nbf = $now.ToUnixTimeSeconds() exp = $now.AddMinutes(10).ToUnixTimeSeconds() } | ConvertTo-Json -Compress $unsigned = "$(& $toBase64Url ([Text.Encoding]::UTF8.GetBytes($header))).$(& $toBase64Url ([Text.Encoding]::UTF8.GetBytes($claims)))" $signature = $privateKey.SignData( [Text.Encoding]::UTF8.GetBytes($unsigned), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 ) return "$unsigned.$(& $toBase64Url $signature)" } #Function to get the most useful message from a failed Microsoft Graph request function Get-GraphErrorMessage { param ( [Parameter(Mandatory = $true)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) $message = $ErrorRecord.Exception.Message $details = $ErrorRecord.ErrorDetails.Message if ([string]::IsNullOrWhiteSpace($details)) { return $message } #Graph returns {"error": {"code": "...", "message": "..."}} in the response body try { $graphError = ($details | ConvertFrom-Json -ErrorAction Stop).error if ($graphError.message) { return "$message $($graphError.code): $($graphError.message)" } } catch { Write-Verbose "Error details are not Graph JSON: $details" } return "$message $details" } #Function to stop the calling cmdlet with a terminating error that -ErrorAction can't suppress #A Throw in a begin block is skipped with -ErrorAction SilentlyContinue, after which process still runs function Stop-CPCCommand { param ( [Parameter(Mandatory = $true)] [System.Management.Automation.PSCmdlet]$Cmdlet, [Parameter(Mandatory = $true)] [string]$Message, [System.Management.Automation.ErrorCategory]$Category = 'InvalidArgument' ) $errorRecord = [System.Management.Automation.ErrorRecord]::new([System.Exception]::new($Message), 'PSCloudPC.Guard', $Category, $null) $Cmdlet.ThrowTerminatingError($errorRecord) } #Function to read a Cloud PC user setting and return its writable properties as a hashtable for a PATCH body #PATCH on userSettings replaces the whole object: every writable property left out of the body goes back to its default, #and displayName is required. The update cmdlets therefore send the current values back and change only what was passed. #Every property the GET returns is copied, so a writable property Microsoft adds later is kept as well; left out are the #read-only ones (id, createdDateTime, lastModifiedDateTime, @odata.*), the assignments relationship, null values and the #retired ones (selfServiceEnabled, notificationSetting, restorePointSetting.frequencyInHours). #https://learn.microsoft.com/graph/api/cloudpcusersetting-get?view=graph-rest-beta #https://learn.microsoft.com/graph/api/cloudpcusersetting-update?view=graph-rest-beta #https://learn.microsoft.com/graph/api/resources/cloudpcusersetting?view=graph-rest-beta function Get-CPCUserSettingBody { param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Id ) $url = "https://graph.microsoft.com/$script:MSGraphVersion/deviceManagement/virtualEndpoint/userSettings/$Id" $current = Invoke-APIRequest -uri $url -Token $script:Authtoken -Method GET if ($null -eq $current -or [string]::IsNullOrWhiteSpace($current.displayName)) { Throw "User setting $Id was not returned with a displayName, which every update requires" } #Read-only properties, the assignments relationship (only returned with $expand) and retired properties: #selfServiceEnabled stopped working on December 1, 2023 and notificationSetting on July 14, 2026 $skipProperties = "id", "createdDateTime", "lastModifiedDateTime", "assignments", "selfServiceEnabled", "notificationSetting" $body = @{} foreach ($property in $current.PSObject.Properties) { if ($property.Name -like "@odata.*" -or $property.Name -in $skipProperties -or $null -eq $property.Value) { continue } $body[$property.Name] = ConvertTo-CPCRequestValue -Value $property.Value } #The deprecated frequencyInHours is not sent; frequencyType replaces it if ($body["restorePointSetting"] -is [hashtable]) { $body["restorePointSetting"].Remove("frequencyInHours") if ($body["restorePointSetting"].Count -eq 0) { $body.Remove("restorePointSetting") } } if ($body["crossRegionDisasterRecoverySetting"] -is [hashtable]) { $drSetting = $body["crossRegionDisasterRecoverySetting"] $disasterRecoveryType = $drSetting["disasterRecoveryType"] if ([string]::IsNullOrWhiteSpace($disasterRecoveryType) -and $drSetting["crossRegionDisasterRecoveryEnabled"] -eq $true) { #Documented replacement for a setting that only has the deprecated flag $disasterRecoveryType = "crossRegion" } if ([string]::IsNullOrWhiteSpace($disasterRecoveryType)) { #Without a disasterRecoveryType Graph rejects the setting; left out, it stays at its default (notConfigured) $body.Remove("crossRegionDisasterRecoverySetting") } else { #crossRegionDisasterRecoveryEnabled is deprecated, but Graph still rejects crossRegion unless it is true $drSetting["disasterRecoveryType"] = $disasterRecoveryType $drSetting["crossRegionDisasterRecoveryEnabled"] = ($disasterRecoveryType -ne "notConfigured") } } return $body } #Function to copy a value from ConvertFrom-Json into hashtables and arrays for a request body #Null values and @odata annotations are left out. @odata.type is kept on these nested objects: Graph returns it for #derived types, such as the Microsoft-hosted or Azure network connection disaster recovery network setting, and needs it #to tell them apart. function ConvertTo-CPCRequestValue { param ( [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyString()] [AllowEmptyCollection()] $Value ) if ($Value -is [System.Management.Automation.PSCustomObject]) { $result = @{} foreach ($property in $Value.PSObject.Properties) { if ($null -eq $property.Value -or ($property.Name -like "@odata.*" -and $property.Name -ne "@odata.type")) { continue } $result[$property.Name] = ConvertTo-CPCRequestValue -Value $property.Value } return $result } if ($Value -is [array]) { $items = @(foreach ($item in $Value) { if ($null -ne $item) { ConvertTo-CPCRequestValue -Value $item } }) return , $items } return $Value } |