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) }
}