Private/TokenCache.ps1
|
# Remembering a sign-in, so a Run does not ask for one it already has. # # ADR-0004's property is that a Technician leaving a Customer Site leaves no access # behind. That is why Private/PublishedDefinitions.ps1 writes nothing, and why a test # asserts against its parsed code that it never will. This file is the deliberate # exception, and it is separate for exactly that reason: everything that can persist a # token is here, in one file named for it, rather than spread through the fetch. # # A Kept Sign-In is held on two different terms. CONTEXT.md defines the term; what follows # is why there are two. # # In memory, always. A second Run in the same PowerShell session reuses the first Run's # token. Nothing reaches disk - close the window and the token is gone with it - so there # is nothing to ask about. # # On disk, only when the Technician says they will come back - Private/KeepSignIn.ps1 # asks. Yes keeps the sign-in here for seven days, refresh token included, so later Runs on # this machine neither sign in nor ask again. The seven days run from the answer, not from # the last Run, so a machine visited every few days does not keep a sign-in for ever. # # The file is encrypted to this Windows user on this machine, so a copy of it is useless # elsewhere. It is not protected from that Windows user - which, at a Customer Site, is # usually the Customer's employee. That is what the question is for, and why the answer # defaults to no. What a kept sign-in can reach is the Gutcheck site and nothing else # (Sites.Selected - see PublishedDefinitions.ps1), and it can only read it. # # The seven days are Gutcheck's promise, and Gutcheck can only keep it on the disk. The # refresh token itself is Entra's, and Entra would honour it for far longer - weeks, and # longer still each time it is used. So a sign-in past its week is not only refused here: # every Run removes it from the file, and a file with nothing left in it goes. What a Run # cannot do is reach a machine it never runs on again, or recall a token from Entra. A # refresh token copied off before its week was up stays redeemable until Entra expires # it, unless the tenant's Conditional Access limits sign-in frequency for this app. ADR-0004 # says so rather than promising a week that only Gutcheck enforces. # # What is never kept, on either term, is the Check Definitions. A remembered token saves a # prompt; a remembered document would make Provenance a lie. # Overridable so a test can point the file somewhere disposable. A test that wrote to the # real profile would leave a working sign-in on whatever machine ran the suite. $script:TokenCacheDirectory = $null $script:TokenCacheMemory = @{} # How much of a token's life has to be left before it is worth handing to a Run. A token # with seconds on it passes a check at the start and is refused by Graph in the middle, # which reads as the Definitions being unreachable rather than as a sign-in that needed # renewing. $script:TokenCacheMarginSeconds = 120 function Get-TokenCachePath { <# .SYNOPSIS Where a kept sign-in lives. Under this user, never machine-wide. #> [CmdletBinding()] [OutputType([string])] param() $directory = $script:TokenCacheDirectory if (-not $directory) { $directory = Join-Path $env:LOCALAPPDATA 'Gutcheck' } Join-Path $directory 'token.cache' } function Get-TokenCacheKey { <# .SYNOPSIS Which sign-in a token belongs to. Pure. .DESCRIPTION Hashed rather than stored, because this goes in a file. None of the three is a secret - ADR-0003 - but a file that names a tenant and a document library tells somebody who found it where to go looking, and it costs nothing not to. The document is part of the key. -PublishedDefinitions pointing somewhere else is a different library being asked a different question, and answering it with the first library's token would read the wrong document under the right Provenance. So is the permission. A token keeps what it was issued with for the rest of its life, so one kept before the permission was narrowed can still open everything the old one did. Keyed on the scope, a module that asks for less never picks up a token that was issued for more - it signs in again instead. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyString()][string]$TenantId, [AllowNull()][AllowEmptyString()][string]$ClientId, [AllowNull()][AllowEmptyString()][string]$Address, [AllowNull()][AllowEmptyString()][string]$Scope = $script:GraphScope ) $material = ('{0}|{1}|{2}|{3}' -f "$TenantId".Trim(), "$ClientId".Trim(), "$Address".Trim(), "$Scope".Trim()).ToLowerInvariant() $sha = [Security.Cryptography.SHA256]::Create() try { $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($material)) ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' } finally { $sha.Dispose() } } function New-TokenCacheEntry { <# .SYNOPSIS What is remembered about one sign-in. Pure. .DESCRIPTION An access token and when it stops being one; and, for a sign-in the Technician chose to keep, the refresh token that renews it and the moment the keeping ends. A sign-in with no KeptUntil is not kept, and is never written down. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][string]$Key, [Parameter(Mandatory)][AllowEmptyString()][string]$Token, [int]$ExpiresInSeconds = 3600, [AllowNull()][AllowEmptyString()][string]$RefreshToken, [AllowNull()][Nullable[datetime]]$KeptUntil, [datetime]$AsOf = (Get-Date) ) [pscustomobject]@{ PSTypeName = 'Gutcheck.TokenCacheEntry' Key = $Key AccessToken = $Token ExpiresOn = $AsOf.AddSeconds($ExpiresInSeconds) RefreshToken = $(if ($KeptUntil) { $RefreshToken } else { $null }) KeptUntil = $KeptUntil } } function Test-TokenCacheEntry { <# .SYNOPSIS Whether a remembered access token may still be used for this sign-in. Pure. #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()]$Entry, [Parameter(Mandatory)][string]$Key, [datetime]$AsOf = (Get-Date) ) if (-not $Entry) { return $false } if (-not $Entry.AccessToken) { return $false } if ("$($Entry.Key)" -ne $Key) { return $false } if (-not $Entry.ExpiresOn) { return $false } if ($Entry.KeptUntil -and ([datetime]$Entry.KeptUntil) -le $AsOf) { return $false } ([datetime]$Entry.ExpiresOn) -gt $AsOf.AddSeconds($script:TokenCacheMarginSeconds) } function Test-KeptSignInRenewable { <# .SYNOPSIS Whether a kept sign-in can be renewed without asking anybody. Pure. .DESCRIPTION Only within the days the Technician agreed to, however long the refresh token itself would last. The limit is their answer, not Entra's. #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()]$Entry, [Parameter(Mandatory)][string]$Key, [datetime]$AsOf = (Get-Date) ) if (-not $Entry -or -not $Entry.RefreshToken -or -not $Entry.KeptUntil) { return $false } if ("$($Entry.Key)" -ne $Key) { return $false } ([datetime]$Entry.KeptUntil) -gt $AsOf } function Get-CachedFetchToken { <# .SYNOPSIS A sign-in this session already made, or one kept on this machine. Returns nothing when there is none worth using. .DESCRIPTION Token is the access token when it can be used as it is. When only renewing would work - a kept sign-in whose access token has run out - Token is empty and RefreshToken is set, and the caller renews it. Source says whether it came from this session or off this machine, because the two mean different things to the person watching, and KeptUntil says how long it stays. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][string]$Key, [datetime]$AsOf = (Get-Date) ) foreach ($source in 'Session', 'File') { $entry = $(if ($source -eq 'Session') { $script:TokenCacheMemory[$Key] } else { (Read-TokenCacheFile)[$Key] }) if (-not $entry) { continue } $usable = Test-TokenCacheEntry -Entry $entry -Key $Key -AsOf $AsOf $renewable = Test-KeptSignInRenewable -Entry $entry -Key $Key -AsOf $AsOf if (-not $usable -and -not $renewable) { continue } # Back into memory, so the rest of this session does not read the file again. if ($source -eq 'File') { $script:TokenCacheMemory[$Key] = $entry } return [pscustomobject]@{ PSTypeName = 'Gutcheck.CachedSignIn' Token = $(if ($usable) { $entry.AccessToken } else { $null }) RefreshToken = $entry.RefreshToken KeptUntil = $entry.KeptUntil Source = $source } } } function Save-FetchToken { <# .SYNOPSIS Remembers a sign-in for the rest of this session, and on this machine when it is kept. .PARAMETER KeptUntil When the keeping ends. Given only for a sign-in the Technician chose to keep, and it is what puts the sign-in on disk: without it nothing is written. #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory)][string]$Key, [Parameter(Mandatory)][string]$Token, [int]$ExpiresInSeconds = 3600, [AllowNull()][AllowEmptyString()][string]$RefreshToken, [AllowNull()][Nullable[datetime]]$KeptUntil ) $entry = New-TokenCacheEntry -Key $Key -Token $Token -ExpiresInSeconds $ExpiresInSeconds ` -RefreshToken $RefreshToken -KeptUntil $KeptUntil $script:TokenCacheMemory[$Key] = $entry if ($KeptUntil) { # Merged into what is already kept rather than replacing it, so keeping a second # library does not forget the first. $kept = Read-TokenCacheFile $kept[$Key] = $entry Write-TokenCacheFile -Entries $kept } } function Remove-CachedFetchToken { <# .SYNOPSIS Forgets one sign-in, in this session and on this machine, because it turned out not to be one any more. .DESCRIPTION A token Graph refuses, or a refresh token Entra will not renew, is worse than none: kept, every Run would offer it again and fall back to Local Definitions without anybody being asked to sign in. Dropping it turns that into one wasted request and a fresh sign-in. #> [CmdletBinding()] [OutputType([void])] param([Parameter(Mandatory)][string]$Key) $script:TokenCacheMemory.Remove($Key) $kept = Read-TokenCacheFile if ($kept.ContainsKey($Key)) { $kept.Remove($Key) Write-TokenCacheFile -Entries $kept } } function Clear-FetchTokenCache { <# .SYNOPSIS Forgets every sign-in, in this session and on this machine. Never throws. .DESCRIPTION Wrapped for the same reason reading is: a Technician who asked for the sign-in to come off this machine is in the middle of a Run, and a profile path that cannot be built is not a reason to end it. The memory half is cleared first, so it happens whatever the disk does. #> [CmdletBinding()] [OutputType([void])] param() $script:TokenCacheMemory = @{} try { $path = Get-TokenCachePath if ($path -and (Test-Path -LiteralPath $path)) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } } catch { Write-Verbose ('Could not remove the kept sign-in: {0}' -f $_.Exception.Message) } } function Remove-ExpiredKeptSignIn { <# .SYNOPSIS Takes every sign-in past its week off this machine. Never throws. .DESCRIPTION Refusing an expired sign-in is not the same as removing it. Its refresh token is still in the file, Entra would still honour it, and anything running as this Windows user can decrypt it and use it without Gutcheck. So every Run calls this, whether it fetches, keeps, or neither: whatever is no longer kept is rewritten out of the file, and a file with nothing left in it is deleted. Reads and rewrites rather than inspecting, because Write-TokenCacheFile already knows what "no longer kept" means and a second definition here would drift. #> [CmdletBinding()] [OutputType([void])] param([datetime]$AsOf = (Get-Date)) try { $path = Get-TokenCachePath if (-not $path -or -not (Test-Path -LiteralPath $path)) { return } Write-TokenCacheFile -Entries (Read-TokenCacheFile) -AsOf $AsOf } catch { Write-Verbose ('Could not tidy the kept sign-in: {0}' -f $_.Exception.Message) } } function Read-TokenCacheFile { <# .SYNOPSIS Every kept sign-in, keyed as they were saved. Empty when there are none. Never throws. .DESCRIPTION A file that cannot be read is a reason to sign in again, not a reason for a Run to fail at a machine somebody is standing in front of. A half-written file, one written by another user, one copied from another machine, and one somebody edited by hand all arrive here the same way and all mean the same thing: no sign-in. An entry with no KeptUntil is ignored. Those were written before the question existed, possibly under the old, far wider permission, and nobody agreed to them being kept. The next write drops them. #> [CmdletBinding()] [OutputType([hashtable])] param() $entries = @{} $path = Get-TokenCachePath if (-not $path -or -not (Test-Path -LiteralPath $path)) { return $entries } try { $protected = Get-Content -LiteralPath $path -Raw -ErrorAction Stop if (-not "$protected".Trim()) { return $entries } # DPAPI, through SecureString: decrypts only for this user on this machine. $secure = ConvertTo-SecureString -String "$protected".Trim() -ErrorAction Stop $json = ConvertFrom-SecureStringToPlainText -Secure $secure $stored = $json | ConvertFrom-Json -ErrorAction Stop foreach ($property in @($stored.PSObject.Properties)) { $value = $property.Value $expires = ConvertFrom-RoundTripDate -Text $value.ExpiresOn $until = ConvertFrom-RoundTripDate -Text $value.KeptUntil if (-not $expires -or -not $until) { continue } # A keep-until further out than the question ever promises was not agreed to by # anybody - a hand-edited file, a build with a longer week - so it is not kept. # The hour's slack is for a clock that moved, not for a longer agreement. if ($until -gt (Get-KeptUntil).AddHours(1)) { continue } $entries[$property.Name] = [pscustomobject]@{ PSTypeName = 'Gutcheck.TokenCacheEntry' Key = $property.Name AccessToken = [string]$value.AccessToken ExpiresOn = $expires RefreshToken = [string]$value.RefreshToken KeptUntil = $until } } $entries } catch { # Said out loud, even though it changes nothing: a file that cannot be read looks # exactly like no file at all, and somebody wondering why they are being asked to # sign in every time deserves a way to find out. Write-Verbose ('Could not read the kept sign-in: {0}' -f $_.Exception.Message) @{} } } function ConvertFrom-RoundTripDate { <# .SYNOPSIS A date written with ToString('o'), or $null. Pure. #> [CmdletBinding()] param([AllowNull()]$Text) if (-not $Text) { return $null } $parsed = [datetime]::MinValue if ([datetime]::TryParse([string]$Text, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind, [ref]$parsed)) { return $parsed } $null } function Write-TokenCacheFile { <# .SYNOPSIS Keeps the sign-ins on this machine, encrypted to this user. .DESCRIPTION Takes the whole set rather than one, so that keeping a second sign-in does not drop the first. Anything that is no longer kept is dropped on the way out: past its KeptUntil, with nothing left to renew it, or with no KeptUntil at all. A file that only ever grew would hold a token long after anybody agreed to it. #> [CmdletBinding()] [OutputType([void])] param( # Plural, and not $Entry: PowerShell variable names are case-insensitive, so a # loop variable called $entry inside a function with an [hashtable]$Entry # parameter assigns each entry back over the parameter and through its type # constraint. It throws, this function catches, and nothing is ever written - # silently, and only at runtime. Private/Definition.ps1 carries the same scar. [Parameter(Mandatory)][hashtable]$Entries, [datetime]$AsOf = (Get-Date) ) $path = Get-TokenCachePath $directory = Split-Path $path -Parent try { $keeping = [ordered]@{} foreach ($key in @($Entries.Keys | Sort-Object)) { $kept = $Entries[$key] if (-not $kept -or -not $kept.KeptUntil) { continue } if (([datetime]$kept.KeptUntil) -le $AsOf) { continue } if (-not $kept.RefreshToken -and ([datetime]$kept.ExpiresOn) -le $AsOf) { continue } $keeping[$key] = [ordered]@{ AccessToken = $kept.AccessToken ExpiresOn = ([datetime]$kept.ExpiresOn).ToString('o') RefreshToken = $kept.RefreshToken KeptUntil = ([datetime]$kept.KeptUntil).ToString('o') } } if (-not $keeping.Count) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } return } if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop | Out-Null } $json = $keeping | ConvertTo-Json -Compress -Depth 4 $protected = ConvertTo-SecureString -String $json -AsPlainText -Force | ConvertFrom-SecureString Set-Content -LiteralPath $path -Value $protected -Encoding ASCII -ErrorAction Stop } catch { # Not being able to keep the sign-in is not a failed Run. The Run has its token. Write-Verbose ('Could not keep the sign-in: {0}' -f $_.Exception.Message) } } function ConvertFrom-SecureStringToPlainText { <# .SYNOPSIS The text inside a SecureString. 5.1 has no -AsPlainText on ConvertFrom-SecureString. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][Security.SecureString]$Secure) $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } |