Skip to content

Commit fa9ebd5

Browse files
authored
Reapply "Add function for checking and caching GitHub Tokens" (#236266) (#236478)
1 parent 567e758 commit fa9ebd5

File tree

2 files changed

+207
-22
lines changed

2 files changed

+207
-22
lines changed

Tools/SandboxTest.ps1

+199-18
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ enum DependencySources {
4646

4747
# Script Behaviors
4848
$ProgressPreference = 'SilentlyContinue'
49-
$ErrorActionPreference = 'Stop' # This gets overriden most places, but is set explicitly here to help catch errors
49+
$ErrorActionPreference = 'Stop' # This gets overridden most places, but is set explicitly here to help catch errors
5050
if ($PSBoundParameters.Keys -notcontains 'InformationAction') { $InformationPreference = 'Continue' } # If the user didn't explicitly set an InformationAction, Override their preference
5151
$script:OnMappedFolderWarning = ($PSBoundParameters.Keys -contains 'WarningAction') ? $PSBoundParameters.WarningAction : 'Inquire'
5252
$script:UseNuGetForMicrosoftUIXaml = $false
@@ -56,6 +56,7 @@ $script:DependenciesBaseName = 'DesktopAppInstaller_Dependencies'
5656
$script:ReleasesApiUrl = 'https://api.github.com/repos/microsoft/winget-cli/releases?per_page=100'
5757
$script:DependencySource = [DependencySources]::InRelease
5858
$script:UsePowerShellModuleForInstall = $false
59+
$script:CachedTokenExpiration = 30 # Days
5960

6061
# File Names
6162
$script:AppInstallerMsixFileName = "$script:AppInstallerPFN.msixbundle" # This should exactly match the name of the file in the CLI GitHub Release
@@ -75,6 +76,7 @@ $script:UiLibsHash_NuGet = '6B62BD3C277F55518C3738121B77585AC5E171C154936EC58D87
7576

7677
# File Paths
7778
$script:AppInstallerDataFolder = Join-Path -Path $env:LOCALAPPDATA -ChildPath 'Packages' -AdditionalChildPath $script:AppInstallerPFN
79+
$script:TokenValidationCache = Join-Path -Path $script:AppInstallerDataFolder -ChildPath 'TokenValidationCache'
7880
$script:DependenciesCacheFolder = Join-Path -Path $script:AppInstallerDataFolder -ChildPath "$script:ScriptName.Dependencies"
7981
$script:TestDataFolder = Join-Path -Path $script:AppInstallerDataFolder -ChildPath $script:ScriptName
8082
$script:PrimaryMappedFolder = (Resolve-Path -Path $MapFolder).Path
@@ -92,6 +94,10 @@ $script:HostGeoID = (Get-WinHomeLocation).GeoID
9294
$script:HttpClient = New-Object System.Net.Http.HttpClient
9395
$script:CleanupPaths = @()
9496

97+
# Removed the `-GitHubToken`parameter, always use environment variable
98+
# It is possible that the environment variable may not exist, in which case this may be null
99+
$script:GitHubToken = $env:WINGET_PKGS_GITHUB_TOKEN
100+
95101
# The experimental features get updated later based on a switch that is set
96102
$script:SandboxWinGetSettings = @{
97103
'$schema' = 'https://aka.ms/winget-settings.schema.json'
@@ -147,11 +153,40 @@ function Initialize-Folder {
147153

148154
####
149155
# Description: Gets the details for a specific WinGet CLI release
150-
# Inputs: None
156+
# Inputs: Nullable GitHub API Token
151157
# Outputs: Nullable Object containing GitHub release details
152158
####
153159
function Get-Release {
154-
$releasesAPIResponse = Invoke-RestMethod $script:ReleasesApiUrl
160+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '',
161+
Justification='The standard workflow that users use with other applications requires the use of plaintext GitHub Access Tokens')]
162+
163+
param (
164+
[Parameter()]
165+
[AllowEmptyString()]
166+
[String] $GitHubToken
167+
)
168+
169+
# Build up the API request parameters here so the authentication can be added if the user's token is valid
170+
$requestParameters = @{
171+
Uri = $script:ReleasesApiUrl
172+
}
173+
174+
if (Test-GithubToken -Token $GitHubToken) {
175+
# The validation function will return True only if the provided token is valid
176+
Write-Verbose 'Adding Bearer Token Authentication to Releases API Request'
177+
$requestParameters.Add('Authentication', 'Bearer')
178+
$requestParameters.Add('Token', $(ConvertTo-SecureString $GitHubToken -AsPlainText))
179+
}
180+
else {
181+
# No token was provided or the token has expired
182+
# If an invalid token was provided, an exception will have been thrown before this code is reached
183+
Write-Warning @"
184+
A valid GitHub token was not provided. You may encounter API rate limits.
185+
Please consider adding your token using the `WINGET_PKGS_GITHUB_TOKEN` environment variable.
186+
"@
187+
}
188+
189+
$releasesAPIResponse = Invoke-RestMethod @requestParameters
155190
if (!$script:Prerelease) {
156191
$releasesAPIResponse = $releasesAPIResponse.Where({ !$_.prerelease })
157192
}
@@ -274,6 +309,152 @@ function Test-FileChecksum {
274309
return ($currentHash -and $currentHash.Hash -eq $ExpectedChecksum)
275310
}
276311

312+
####
313+
# Description: Checks that a provided GitHub token is valid
314+
# Inputs: Token
315+
# Outputs: Boolean
316+
# Notes:
317+
# This function hashes the provided GitHub token. If the provided token is valid, a file is added to the token cache with
318+
# the name of the hashed token and the token expiration date. To avoid making unnecessary calls to the GitHub APIs, this
319+
# function checks the token cache for the existence of the file. If the file is older than 30 days, it is removed and the
320+
# token is re-checked. If the file has content, the date is checked to see if the token is expired. This can't catch every
321+
# edge case, but it should catch a majority of the use cases.
322+
####
323+
function Test-GithubToken {
324+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '',
325+
Justification='The standard workflow that users use with other applications requires the use of plaintext GitHub Access Tokens')]
326+
327+
param (
328+
[Parameter(Mandatory = $true)]
329+
[AllowEmptyString()]
330+
[String] $Token
331+
)
332+
333+
# If the token is empty, there is no way that it can be valid
334+
if ([string]::IsNullOrWhiteSpace($Token)) { return $false }
335+
336+
Write-Verbose 'Hashing GitHub Token'
337+
$_memoryStream = [System.IO.MemoryStream]::new()
338+
$_streamWriter = [System.IO.StreamWriter]::new($_memoryStream)
339+
$_streamWriter.Write($Token)
340+
$_streamWriter.Flush()
341+
$_memoryStream.Position = 0
342+
343+
$tokenHash = Get-FileHash -InputStream $_memoryStream | Select-Object -ExpandProperty Hash
344+
345+
# Dispose of the reader and writer for hashing the token to ensure they cannot be accessed outside of the intended scope
346+
Write-Debug 'Disposing of hashing components'
347+
$_streamWriter.DisposeAsync() 1> $null
348+
$_memoryStream.DisposeAsync() 1> $null
349+
350+
# Check for the cached token file
351+
Initialize-Folder -FolderPath $script:TokenValidationCache | Out-Null
352+
$cachedToken = Get-ChildItem -Path $script:TokenValidationCache -Filter $tokenHash -ErrorAction SilentlyContinue
353+
354+
if ($cachedToken) {
355+
Write-Verbose 'Token was found in the cache'
356+
# Check the age of the cached file
357+
$cachedTokenAge = (Get-Date) - $cachedToken.LastWriteTime | Select-Object -ExpandProperty TotalDays
358+
$cachedTokenAge = [Math]::Round($cachedTokenAge, 2) # We don't need all the precision the system provides
359+
Write-Debug "Token has been in the cache for $cachedTokenAge days"
360+
$cacheIsExpired = $cachedTokenAge -ge $script:CachedTokenExpiration
361+
$cachedTokenContent = (Get-Content $cachedToken -Raw).Trim() # Ensure any trailing whitespace is ignored
362+
$cachedTokenIsEmpty = [string]::IsNullOrWhiteSpace($cachedTokenContent)
363+
364+
# It is possible for a token to be both empty and expired. Since these are debug and verbose messages, showing both doesn't hurt
365+
if ($cachedTokenIsEmpty) { Write-Verbose 'Cached token had no content. It will be re-validated' }
366+
if ($cacheIsExpired) { Write-Verbose "Cached token is older than $script:CachedTokenExpiration days. It will be re-validated" }
367+
368+
if (!$cacheIsExpired -and !$cachedTokenIsEmpty) {
369+
# Check the content of the cached file in case the actual token expiration is known
370+
Write-Verbose 'Attempting to fetch token expiration from cache'
371+
# Since Github adds ` UTC` at the end, it needs to be stripped off. Trim is safe here since the last character should always be a digit or AM/PM
372+
$cachedExpirationForParsing = $cachedTokenContent.TrimEnd(' UTC')
373+
$cachedExpirationDate = [System.DateTime]::MinValue
374+
# Pipe to Out-Null so that it doesn't get captured in the return output
375+
[System.DateTime]::TryParse($cachedExpirationForParsing, [ref]$cachedExpirationDate) | Out-Null
376+
377+
$tokenExpirationDays = $cachedExpirationDate - (Get-Date) | Select-Object -ExpandProperty TotalDays
378+
$tokenExpirationDays = [Math]::Round($tokenExpirationDays, 2) # We don't need all the precision the system provides
379+
380+
if ($cachedExpirationForParsing -eq [System.DateTime]::MaxValue.ToLongDateString().Trim()) {
381+
Write-Verbose "The cached token contained content. It is set to never expire"
382+
return $true
383+
}
384+
385+
if ($tokenExpirationDays -gt 0) {
386+
Write-Verbose "The cached token contained content. It should expire in $tokenExpirationDays days"
387+
return $true
388+
}
389+
# If the parsing failed, the expiration should still be at the minimum value
390+
elseif ($cachedExpirationDate -eq [System.DateTime]::MinValue) {
391+
Write-Verbose 'The cached token contained content, but it could not be parsed as a date. It will be re-validated'
392+
Invoke-FileCleanup -FilePaths $cachedToken.FullName
393+
# Do not return anything, since the token will need to be re-validated
394+
}
395+
else {
396+
Write-Verbose "The cached token contained content, but the token expired $([Math]::Abs($tokenExpirationDays)) days ago"
397+
# Leave the cached token so that it doesn't throw script exceptions in the future
398+
# Invoke-FileCleanup -FilePaths $cachedToken.FullName
399+
return $false
400+
}
401+
}
402+
else {
403+
# Either the token was empty, or the cached token is expired. Remove the cached token so that re-validation
404+
# of the token will update the date the token was cached if it is still valid
405+
Invoke-FileCleanup -FilePaths $cachedToken.FullName
406+
}
407+
}
408+
else {
409+
Write-Verbose 'Token was not found in the cache'
410+
}
411+
412+
# To get here either the token was not in the cache or it needs to be re-validated
413+
414+
$requestParameters = @{
415+
Uri = 'https://api.github.com/rate_limit'
416+
Authentication = 'Bearer'
417+
Token = $(ConvertTo-SecureString "$Token" -AsPlainText)
418+
}
419+
420+
Write-Verbose "Checking Token against $($requestParameters.Uri)"
421+
$apiResponse = Invoke-WebRequest @requestParameters # This will return an exception if the token is not valid; It is intentionally not caught
422+
# The headers can sometimes be a single string, or an array of strings. Cast them into an array anyways just for safety
423+
$rateLimit = @($apiResponse.Headers['X-RateLimit-Limit'])
424+
$tokenExpiration = @($apiResponse.Headers['github-authentication-token-expiration']) # This could be null if the token is set to never expire.
425+
Write-Debug "API responded with Rate Limit ($rateLimit) and Expiration ($tokenExpiration)"
426+
427+
if (!$rateLimit) { return $false } # Something went horribly wrong, and the rate limit isn't known. Assume the token is not valid
428+
if ([int]$rateLimit[0] -le 60) {
429+
# Authenticated users typically have a limit that is much higher than 60
430+
return $false
431+
}
432+
433+
Write-Verbose 'Token validated successfully. Adding to cache'
434+
# Trim off any non-digit characters from the end
435+
# Strip off the array wrapper since it is no longer needed
436+
$tokenExpiration = $tokenExpiration[0] -replace '[^0-9]+$',''
437+
# If the token doesn't expire, write a special value to the file
438+
if (!$tokenExpiration -or [string]::IsNullOrWhiteSpace($tokenExpiration)) {
439+
Write-Debug "Token expiration was empty, setting it to maximum"
440+
$tokenExpiration = [System.DateTime]::MaxValue
441+
}
442+
# Try parsing the value to a datetime before storing it
443+
if ([DateTime]::TryParse($tokenExpiration,[ref]$tokenExpiration)) {
444+
Write-Debug "Token expiration successfully parsed as DateTime ($tokenExpiration)"
445+
} else {
446+
# TryParse Failed
447+
Write-Warning "Could not parse expiration date as a DateTime object. It will be set to the minimum value"
448+
$tokenExpiration = [System.DateTime]::MinValue
449+
}
450+
# Explicitly convert to a string here to avoid implicit casting
451+
$tokenExpiration = $tokenExpiration.ToString()
452+
# Write the value to the cache
453+
New-Item -ItemType File -Path $script:TokenValidationCache -Name $tokenHash -Value $tokenExpiration | Out-Null
454+
Write-Debug "Token <$tokenHash> added to cache with content <$tokenExpiration>"
455+
return $true
456+
}
457+
277458
#### Start of main script ####
278459

279460
# Check if Windows Sandbox is enabled
@@ -292,7 +473,7 @@ $ Enable-WindowsOptionalFeature -Online -FeatureName 'Containers-DisposableClien
292473
if (!$SkipManifestValidation -and ![String]::IsNullOrWhiteSpace($Manifest)) {
293474
# Check that WinGet is Installed
294475
if (!(Get-Command 'winget.exe' -ErrorAction SilentlyContinue)) {
295-
Write-Error -Category NotInstalled "WinGet is not installed. Manifest cannot be validated" -ErrorAction Continue
476+
Write-Error -Category NotInstalled 'WinGet is not installed. Manifest cannot be validated' -ErrorAction Continue
296477
Invoke-CleanExit -ExitCode 3
297478
}
298479
Write-Information "--> Validating Manifest"
@@ -316,7 +497,7 @@ if (!$SkipManifestValidation -and ![String]::IsNullOrWhiteSpace($Manifest)) {
316497
}
317498
'-1978335192' {
318499
($validateCommandOutput | Select-Object -Skip 1 -SkipLast 1) | Write-Information # Skip the first line and the empty last line
319-
Write-Warning "Manifest validation succeeded with warnings"
500+
Write-Warning 'Manifest validation succeeded with warnings'
320501
Start-Sleep -Seconds 5 # Allow the user 5 seconds to read the warnings before moving on
321502
}
322503
Default {
@@ -327,7 +508,7 @@ if (!$SkipManifestValidation -and ![String]::IsNullOrWhiteSpace($Manifest)) {
327508

328509
# Get the details for the version of WinGet that was requested
329510
Write-Verbose "Fetching release details from $script:ReleasesApiUrl; Filters: {Prerelease=$script:Prerelease; Version~=$script:WinGetVersion}"
330-
$script:WinGetReleaseDetails = Get-Release
511+
$script:WinGetReleaseDetails = Get-Release -GitHubToken $script:GitHubToken
331512
if (!$script:WinGetReleaseDetails) {
332513
Write-Error -Category ObjectNotFound 'No WinGet releases found matching criteria' -ErrorAction Continue
333514
Invoke-CleanExit -ExitCode 1
@@ -337,7 +518,7 @@ if (!$script:WinGetReleaseDetails.assets) {
337518
Invoke-CleanExit -ExitCode 1
338519
}
339520

340-
Write-Verbose "Parsing Release Information"
521+
Write-Verbose 'Parsing Release Information'
341522
# Parse the needed URLs out of the release. It is entirely possible that these could end up being $null
342523
$script:AppInstallerMsixShaDownloadUrl = $script:WinGetReleaseDetails.assets.Where({ $_.name -eq "$script:AppInstallerPFN.txt" }).browser_download_url
343524
$script:AppInstallerMsixDownloadUrl = $script:WinGetReleaseDetails.assets.Where({ $_.name -eq $script:AppInstallerMsixFileName }).browser_download_url
@@ -357,7 +538,7 @@ $script:AppInstallerParsedVersion = [System.Version]($script:AppInstallerRelease
357538
Write-Debug "Using Release version $script:AppinstallerReleaseTag ($script:AppInstallerParsedVersion)"
358539

359540
# Get the hashes for the files that change with each release version
360-
Write-Verbose "Fetching file hash information"
541+
Write-Verbose 'Fetching file hash information'
361542
$script:AppInstallerMsixHash = Get-RemoteContent -URL $script:AppInstallerMsixShaDownloadUrl -Raw
362543
$script:DependenciesZipHash = Get-RemoteContent -URL $script:DependenciesShaDownloadUrl -Raw
363544
Write-Debug @"
@@ -370,7 +551,7 @@ Write-Debug @"
370551
$script:AppInstallerReleaseAssetsFolder = Join-Path $script:AppInstallerDataFolder -ChildPath 'bin' -AdditionalChildPath $script:AppInstallerReleaseTag
371552

372553
# Build the dependency information
373-
Write-Verbose "Building Dependency List"
554+
Write-Verbose 'Building Dependency List'
374555
$script:AppInstallerDependencies = @()
375556
if ($script:AppInstallerParsedVersion -ge [System.Version]'1.9.25180') {
376557
# As of WinGet 1.9.25180, VCLibs no longer publishes to the public URL and must be downloaded from the WinGet release
@@ -386,7 +567,7 @@ if ($script:AppInstallerParsedVersion -ge [System.Version]'1.9.25180') {
386567
else {
387568
$script:DependencySource = [DependencySources]::Legacy
388569
# Add the VCLibs to the dependencies
389-
Write-Debug "Adding VCLibs UWP to dependency list"
570+
Write-Debug 'Adding VCLibs UWP to dependency list'
390571
$script:AppInstallerDependencies += @{
391572
DownloadUrl = $script:VcLibsDownloadUrl
392573
Checksum = $script:VcLibsHash
@@ -395,7 +576,7 @@ else {
395576
}
396577
if ($script:UseNuGetForMicrosoftUIXaml) {
397578
# Add the NuGet file to the dependencies
398-
Write-Debug "Adding Microsoft.UI.Xaml (NuGet) to dependency list"
579+
Write-Debug 'Adding Microsoft.UI.Xaml (NuGet) to dependency list'
399580
$script:AppInstallerDependencies += @{
400581
DownloadUrl = $script:UiLibsDownloadUrl_NuGet
401582
Checksum = $script:UiLibsHash_NuGet
@@ -406,7 +587,7 @@ else {
406587
# As of WinGet 1.7.10514 (https://github.com/microsoft/winget-cli/pull/4218), the dependency on uiLibsUwP was bumped from version 2.7.3 to version 2.8.6
407588
elseif ($script:AppInstallerParsedVersion -lt [System.Version]'1.7.10514') {
408589
# Add Xaml 2.7 to the dependencies
409-
Write-Debug "Adding Microsoft.UI.Xaml (v2.7) to dependency list"
590+
Write-Debug 'Adding Microsoft.UI.Xaml (v2.7) to dependency list'
410591
$script:AppInstallerDependencies += @{
411592
DownloadUrl = $script:UiLibsDownloadUrl_v2_7
412593
Checksum = $script:UiLibsHash_v2_7
@@ -416,7 +597,7 @@ else {
416597
}
417598
else {
418599
# Add Xaml 2.8 to the dependencies
419-
Write-Debug "Adding Microsoft.UI.Xaml (v2.8) to dependency list"
600+
Write-Debug 'Adding Microsoft.UI.Xaml (v2.8) to dependency list'
420601
$script:AppInstallerDependencies += @{
421602
DownloadUrl = $script:UiLibsDownloadUrl_v2_8
422603
Checksum = $script:UiLibsHash_v2_8
@@ -444,7 +625,7 @@ if ($script:UsePowerShellModuleForInstall) {
444625
}
445626

446627
# Process the dependency list
447-
Write-Information "--> Checking Dependencies"
628+
Write-Information '--> Checking Dependencies'
448629
foreach ($dependency in $script:AppInstallerDependencies) {
449630
# On a clean install, remove the existing files
450631
if ($Clean) { Invoke-FileCleanup -FilePaths $dependency.SaveTo }
@@ -476,7 +657,7 @@ Stop-NamedProcess -ProcessName 'WindowsSandboxRemoteSession'
476657
Start-Sleep -Milliseconds 5000 # Wait for the lock on the file to be released
477658

478659
# Remove the test data folder if it exists. We will rebuild it with new test data
479-
Write-Verbose "Cleaning up previous test data"
660+
Write-Verbose 'Cleaning up previous test data'
480661
Invoke-FileCleanup -FilePaths $script:TestDataFolder
481662

482663
# Create the paths if they don't exist
@@ -485,7 +666,7 @@ if (!(Initialize-Folder $script:DependenciesCacheFolder)) { throw 'Could not cre
485666

486667
# Set Experimental Features to be Enabled, If requested
487668
if ($EnableExperimentalFeatures) {
488-
Write-Debug "Setting Experimental Features to Enabled"
669+
Write-Debug 'Setting Experimental Features to Enabled'
489670
$experimentalFeatures = @($script:SandboxWinGetSettings.experimentalFeatures.Keys)
490671
foreach ($feature in $experimentalFeatures) {
491672
$script:SandboxWinGetSettings.experimentalFeatures[$feature] = $true
@@ -505,7 +686,7 @@ if (-Not [String]::IsNullOrWhiteSpace($Script)) {
505686
}
506687

507688
# Create the bootstrapping script
508-
Write-Verbose "Creating the script for bootstrapping the sandbox"
689+
Write-Verbose 'Creating the script for bootstrapping the sandbox'
509690
@"
510691
function Update-EnvironmentVariables {
511692
foreach(`$level in "Machine","User") {
@@ -613,7 +794,7 @@ Pop-Location
613794

614795
# Create the WSB file
615796
# Although this could be done using the native XML processor, it's easier to just write the content directly as a string
616-
Write-Verbose "Creating WSB file for launching the sandbox"
797+
Write-Verbose 'Creating WSB file for launching the sandbox'
617798
@"
618799
<Configuration>
619800
<Networking>Enable</Networking>

0 commit comments

Comments
 (0)