From 3a909c76e0b92c391b96cd2ce6502f3e71aef538 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:47:05 -0700 Subject: [PATCH] Implements dynamic retrieval of Lenovo PSREF API token Adds a new function to programmatically retrieve the required authentication token for the Lenovo PSREF API. This change is necessary as Lenovo is now restricting API access without a JavaScript-generated token. The new function launches a headless browser instance, uses the DevTools protocol to extract the token from local storage, and then terminates the browser. This ensures continued access to the comprehensive model data available through the PSREF API, which is not fully present in other catalogs. The User-Agent string has also been updated. --- FFUDevelopment/BuildFFUVM.ps1 | 40 +++-- .../FFU.Common/FFU.Common.Drivers.psm1 | 138 +++++++++++++++++- .../FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 | 14 ++ FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 | 4 +- 4 files changed, 181 insertions(+), 15 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 2f62aee..e8311d0 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -1162,6 +1162,20 @@ function Get-LenovoDrivers { [string]$ModelName ) + # Lenovo is special - they prevent access to the PSREF API without a cookie as of July 2025. + # This cookie must be retrieved via Javascript + # It appears that the cookie is hard-coded. We'll see how long this lasts. + # If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know. + # https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models). + + # $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" + + # Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes. + $lenovoCookie = Get-LenovoPSREFToken + + # Add the cookie to the headers + $Headers["Cookie"] = $lenovoCookie + $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$ModelName" WriteLog "Querying Lenovo PSREF API for model: $ModelName" $OriginalVerbosePreference = $VerbosePreference @@ -1174,23 +1188,25 @@ function Get-LenovoDrivers { $products = @() foreach ($item in $jsonResponse.data) { - $productName = $item.ProductName - $machineTypes = $item.MachineType -split " / " + if (-not [string]::IsNullOrEmpty($item.MachineType) -and -not [string]::IsNullOrEmpty($item.ProductName)) { + $productName = $item.ProductName + $machineTypes = $item.MachineType -split " / " - foreach ($machineType in $machineTypes) { - if ($machineType -eq $ModelName) { - WriteLog "Model name entered is a matching machine type" - $products = @() + foreach ($machineType in $machineTypes) { + if ($machineType -eq $ModelName) { + WriteLog "Model name entered is a matching machine type" + $products = @() + $products += [pscustomobject]@{ + ProductName = $productName + MachineType = $machineType + } + WriteLog "Product Name: $productName Machine Type: $machineType" + return $products + } $products += [pscustomobject]@{ ProductName = $productName MachineType = $machineType } - WriteLog "Product Name: $productName Machine Type: $machineType" - return $products - } - $products += [pscustomobject]@{ - ProductName = $productName - MachineType = $machineType } } } diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 index f0accca..e99968e 100644 --- a/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 @@ -248,9 +248,145 @@ function Test-ExistingDriver { # If neither WIM nor a valid folder exists, return null return $null } +function Get-LenovoPSREFToken { + + <# + .DESCRIPTION + Retrieves the Lenovo PSREF token from the Edge browser's local storage. + + .NOTES + + Lenovo's PSREF site creates a cookie/token via javascript when navigating to the PSREF site. This cookie only needs + to be retrieved once on a single machine, and every machine within the same network will be able to access the PSREF API. + + Using Invoke-Webrequest with sessionvariable or websession doesn't work because the token is created by javascript. + Using edge in headless mode with remote debugging enabled allows for the retrieval of the token via the DevTools protocol. + + You couldn't be more unhappy about this solution than I am, but it works. + + Why use PSREF and not catalogv2.xml? Catalogv2.xml doesn't include all models. PSREF provides an API that can be used to retrieve + the friendly model and machine type information for both business and consumer models. Many EDU devices are deemed consumer. + + System Update and other tools rely on the user to input machine type and model information, but finding the machine type is difficult for some. + Our solution makes it easier to simply type the model name and you can match the machine type to the model name. + + If you have a better solution, please submit a PR or open a discussion on Github. Happy to consider alternatives. An easy way to test + if your alternative works is to see if you can retrieve 100e, 300w, 500w, etc. These don't show up in catalogv2.xml, but they do in PSREF. + #> + + # Path to Edge + $edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe" + + # Any free port works. 9222 is common. + $port = 9222 + $uri = 'https://psref.lenovo.com' + + # Headless run with remote debugging. + $flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri" + $edge = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru + Writelog "Edge process started with PID: $($edge.Id)." + + # Wait a short moment so the target appears. + Start-Sleep -Seconds 3 + + # Find the first page target. + $targets = Invoke-RestMethod "http://localhost:$port/json" + $wsUrl = ($targets | Where-Object type -eq 'page')[0].webSocketDebuggerUrl + + # Connect to that WebSocket. + $socket = [System.Net.WebSockets.ClientWebSocket]::new() + $socket.ConnectAsync($wsUrl, [Threading.CancellationToken]::None).Wait() + + # Helper to send a DevTools command. + function Send-DevToolsCommand { + param([int]$id, [string]$method, [hashtable]$params = @{}) + $cmd = @{ id = $id; method = $method; params = $params } | + ConvertTo-Json -Compress + $data = [Text.Encoding]::UTF8.GetBytes($cmd) + $socket.SendAsync([ArraySegment[byte]]$data, 'Text', $true, + [Threading.CancellationToken]::None).Wait() + } + + # Ask the page to return localStorage['asut']. + Send-DevToolsCommand -id 1 -method 'Runtime.evaluate' -params @{ + expression = "localStorage.getItem('asut')" + } + + # Receive frames until the whole message arrives. + $ms = New-Object System.IO.MemoryStream + $buf = New-Object byte[] 8192 + do { + $seg = [ArraySegment[byte]]::new($buf) + $res = $socket.ReceiveAsync($seg, + [Threading.CancellationToken]::None).Result + $ms.Write($buf, 0, $res.Count) + } until ($res.EndOfMessage) + + $ms.Position = 0 + $json = ([System.IO.StreamReader]::new($ms, [Text.Encoding]::UTF8)).ReadToEnd() | + ConvertFrom-Json + + $token = $json.result.result.value + # Concatenate the token value with X-PSREF-USER-TOKEN= + $token = "X-PSREF-USER-TOKEN=$token" + WriteLog "Retrieved Lenovo PSREF token: $token" + + # Clean up. + $socket.Dispose() + + if ($null -ne $socket) { + $socket.Dispose() + } + + # Find the PID listening on the debugging port for reliable termination. + $listeningPid = $null + try { + # Find the process listening on the specific port. The regex now looks for the local address and port, followed by anything, then LISTENING. + # Dots are escaped for literal matching. + $netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" } + if ($netstatOutput) { + # The last number in the line is the PID + $listeningPid = ($netstatOutput -split '\s+')[-1] + WriteLog "Found Edge process PID $listeningPid listening on port $port. This is the process we will terminate." + } + else { + WriteLog "Could not find any process listening on port $port." + } + } + catch { + WriteLog "Could not run netstat to find listening PID. Error: $($_.Exception.Message)" + } + + # Determine the correct PID to kill. Prioritize the one found via netstat. + $pidToKill = $null + if ($listeningPid) { + $pidToKill = $listeningPid + } + elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) { + $pidToKill = $edgeProcess.Id + WriteLog "Could not find listening process via netstat. Falling back to initial Edge process PID $($pidToKill) for termination." + } + + if ($pidToKill) { + WriteLog "Attempting to terminate Edge process tree with PID: $pidToKill" + try { + taskkill /PID $pidToKill /T /F | Out-Null + WriteLog "Successfully issued termination command for Edge process tree with PID: $pidToKill." + } + catch { + WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. It may have already closed. Error: $($_.Exception.Message)" + } + } + else { + WriteLog "No active Edge process found to terminate." + } + + return $token +} + # -------------------------------------------------------------------------- # SECTION: Module Export # -------------------------------------------------------------------------- -Export-ModuleMember -Function Compress-DriverFolderToWim, Update-DriverMappingJson, Test-ExistingDriver \ No newline at end of file +Export-ModuleMember -Function Compress-DriverFolderToWim, Update-DriverMappingJson, Test-ExistingDriver, Get-LenovoPSREFToken \ No newline at end of file diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 index c3de3b6..cc46f5c 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 @@ -17,6 +17,20 @@ function Get-LenovoDriversModelList { [string]$UserAgent ) + # Lenovo is special - they prevent access to the PSREF API without a cookie as of July 2025. + # This cookie must be retrieved via Javascript + # It appears that the cookie is hard-coded. We'll see how long this lasts. + # If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know. + # https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models). + + # $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" + + # Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes. + $lenovoCookie = Get-LenovoPSREFToken + + # Add the cookie to the headers + $Headers["Cookie"] = $lenovoCookie + WriteLog "Querying Lenovo PSREF API for model/machine type: $ModelSearchTerm" $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$([uri]::EscapeDataString($ModelSearchTerm))" $models = [System.Collections.Generic.List[PSCustomObject]]::new() diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 index bb57081..70c497d 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 @@ -15,7 +15,7 @@ $script:Headers = @{ "Accept-Encoding" = "gzip, deflate, br, zstd" "Accept-Language" = "en-US,en;q=0.9" "Priority" = "u=0, i" - "Sec-Ch-Ua" = "`"Microsoft Edge`";v=`"125`", `"Chromium`";v=`"125`", `"Not.A/Brand`";v=`"24`"" + "Sec-Ch-Ua" = "`"Not)A;Brand`";v=`"8`", `"Chromium`";v=`"138`", `"Microsoft Edge`";v=`"138`"" "Sec-Ch-Ua-Mobile" = "?0" "Sec-Ch-Ua-Platform" = "`"Windows`"" "Sec-Fetch-Dest" = "document" @@ -24,7 +24,7 @@ $script:Headers = @{ "Sec-Fetch-User" = "?1" "Upgrade-Insecure-Requests" = "1" } -$script:UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0' +$script:UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0' function Get-CoreStaticVariables { [CmdletBinding()]