Private/PublishedDefinitions.ps1

# Fetching the Published Definitions.
#
# MERLIN's real Check Definitions - which Servers a Customer depends on, which thresholds
# that Customer disagrees with - cannot ship in a module published to a public gallery.
# They are written and reviewed in the Checks Repo, published from there to a SharePoint
# library, and a Run fetches that published copy at its start. Nothing here touches the
# Checks Repo itself, which is why nothing here is named for it: see CONTEXT.md, and
# docs/adr/0004-entra-id-and-sharepoint-for-check-definitions.md.
#
# The Technician authenticates with the Microsoft 365 account they already have, by typing
# a short code on their own device. Nothing secret is ever typed on a Customer's keyboard.
# The token lives in memory and nothing is written down - unless the Technician says they
# will come back to this machine, in which case the sign-in is kept here for a week.
# Nothing in this file writes it: Private/TokenCache.ps1 does, and only that file may.
#
# With nothing configured, a Run goes straight to Local Definitions without attempting a
# fetch - not an error, and not something to report as one.

# Entra ID's device flow, and Graph. {0} is the tenant.
$script:EntraDeviceCodeFormat = 'https://login.microsoftonline.com/{0}/oauth2/v2.0/devicecode'
$script:EntraTokenFormat      = 'https://login.microsoftonline.com/{0}/oauth2/v2.0/token'
$script:GraphRoot             = 'https://graph.microsoft.com/v1.0'

# Delegated, and selected. A Run reads the dedicated Gutcheck site and nothing else the
# Technician can see - not their OneDrive, not another site, not a Customer's files.
#
# This used to be Files.Read.All, which is delegated too, and delegated was thought to be
# the whole answer: a Run could only read what the Technician standing at the machine could
# already read. It was not the whole answer. "Everything the Technician can read" is every
# file in MERLIN's tenant they have access to, and the token carrying that sat in a process
# on a Customer's machine, under the Customer's Windows account, for as long as it lived.
#
# Sites.Selected grants nothing by being asked for. It opens a site only after an
# administrator has granted this app on that one site - build\New-GutcheckEntraApp.ps1
# does it for the Gutcheck site - and even then only as far as the signed-in Technician can
# read there, because a delegated token is the intersection of the two.
#
# The scope is the boundary that matters. A token sits in a process on a Customer's
# machine, and anyone who copies it out can call anything its scope allows - Gutcheck's
# code is not in that conversation. Test-DefinitionRequest is a second, narrower layer: it
# keeps Gutcheck's own code from sending the token anywhere but that site and that
# document, so a bug or a later change cannot, even if the tenant granted too much. It is
# a guard against Gutcheck, not against somebody holding the token.
#
# One scope, and nothing tenant-wide, ever: tests in PublishedDefinitions.Tests.ps1 fail
# on an .All scope or offline_access, so widening this is a decision somebody has to make
# out loud rather than a line somebody changes.
$script:GraphScope = 'https://graph.microsoft.com/Sites.Selected'

# Asked for in addition, and only, when the Technician says they will come back to this
# machine. It widens nothing a token can reach: it makes Entra issue a refresh token for
# the same permission, which is what lets a kept sign-in last the week it was kept for
# without asking again. A sign-in that is not kept never asks for it.
$script:KeepSignInScope = 'offline_access'

function Get-SignInScope {
    <#
    .SYNOPSIS
        What a sign-in asks Entra for. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([bool]$Keep = $false)

    if ($Keep) { return '{0} {1}' -f $script:GraphScope, $script:KeepSignInScope }
    $script:GraphScope
}

# MERLIN's Entra app registration and the document it publishes to, shipped inside the
# package. Neither is a credential - ADR-0003 - and a Run that fetches by default has to
# know where to fetch from before anyone has typed anything.
#
# EMPTY UNTIL PROVISIONED. The app registration and the SharePoint library do not exist
# yet, so a Run declines to fetch and uses the Local Definitions, saying so in its Report.
# Filling these three in is what turns fetching on; nothing else changes. Change them here
# and in tests/Package.Tests.ps1, which pins what ships - a guard that only fires when
# somebody has to type the same value twice.
# TenantId - Entra ID > Overview > Directory (tenant) ID
# ClientId - Entra ID > App registrations > Gutcheck > Application (client) ID
# Address - <host>:<site path>:<file path>, e.g.
# contoso.sharepoint.com:/sites/Gutcheck:/checks.json
# (a fabricated host: the real one is a MERLIN domain, and this file ships
# to a public gallery)
$script:DefaultTenantId          = '530a1484-0df5-4078-8de4-2c887508eaf7'
$script:DefaultClientId          = '92e5f980-2570-4b53-aa43-cd6bb9c6067f'
$script:DefaultDefinitionAddress = 'merlinedv.sharepoint.com:/sites/gutcheck:/checks.json'

# How long to wait for the Technician to finish on their own device. Long enough to find
# the phone, short enough that a Run does not stall at a Customer Site.
$script:DeviceFlowTimeoutSeconds = 180

# What the device flow polls at when the endpoint does not say, how much slower to go when
# it says it is being asked too often, and the point past which backing off further would
# just spend the Technician's remaining time on one sleep.
$script:DeviceFlowPollSeconds     = 5
$script:DeviceFlowSlowDownSeconds = 5
$script:DeviceFlowMaxPollSeconds  = 30

# How long an access token is treated as good for when Entra's answer does not say.
# Entra reports expires_in and ConvertTo-SignIn reads it; this is only the fallback, and a
# deliberate underestimate of Entra's hour. Being wrong still costs only one refused
# request, because Invoke-DefinitionFetch drops a token Graph will not take and signs in
# again rather than falling back to the Local Definitions.
$script:FetchTokenAssumedLifetimeSeconds = 2700

function Set-FetchTls {
    <#
    .SYNOPSIS
        Forces TLS 1.2 before any HTTPS call this module makes.
    .DESCRIPTION
        Windows PowerShell 5.1 defaults its ServicePointManager to SSL 3.0 and TLS 1.0,
        both of which Entra and Graph refuse. Without this every call fails with a
        connection error that names nothing useful, on exactly the edition most Target
        Machines run. PowerShell 7 negotiates for itself and is unaffected, so this only
        ever adds.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param()

    try {
        if (([Net.ServicePointManager]::SecurityProtocol -band [Net.SecurityProtocolType]::Tls12) -eq 0) {
            [Net.ServicePointManager]::SecurityProtocol =
                [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
        }
    }
    catch { }
}

function ConvertTo-DefinitionAddress {
    <#
    .SYNOPSIS
        Reads where the Published Definitions live into the addresses Graph needs. Pure.
    .DESCRIPTION
        Written as one string - "<host>:<site path>:<file path>" - because it is one fact,
        and three separate settings would be three ways to get it half right. Parsing is
        separate from fetching so what Gutcheck understood can be tested without a tenant,
        which matters because a mistyped site would otherwise surface as "the Definitions
        are unreachable" rather than as the typo it is.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Address)

    if (-not $Address -or -not "$Address".Trim()) { return }

    # Three parts, and the site path is allowed to be empty for a site at the tenant root.
    # A pasted browser URL splits into four and is declined rather than half understood.
    $parts = "$Address".Split(':')
    if ($parts.Count -ne 3) { return }

    $hostname = $parts[0].Trim()
    $sitePath = $parts[1].Trim().TrimEnd('/')
    $filePath = $parts[2].Trim().Trim('/')

    if (-not $hostname -or -not $filePath) { return }
    if ($sitePath -and -not $sitePath.StartsWith('/')) { $sitePath = '/' + $sitePath }

    # The address is where a fetch's reach is decided, so this is where a path that walks
    # out of its site is stopped. Every URL a fetch sends its token to is built from these
    # three parts, and .NET collapses dot segments before a request leaves - so a document
    # of ../../../../me/drive/root/children built a URL that looked derived, passed the
    # request check, and went to the Technician's OneDrive. Found in review and reproduced.
    #
    # A SharePoint host and nothing else, because the site request goes to whatever host
    # this names. And no dot segments, query, fragment, percent escape, backslash or
    # control character anywhere: each is a way for text that was checked to become a
    # different URL by the time it is sent. Spaces stay allowed - a document library is
    # full of them - and are escaped when the item path is built.
    if ($hostname -notmatch '\A[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.sharepoint\.com\z') { return }

    foreach ($part in $sitePath, $filePath) {
        if ($part -match '[?#%\\]' -or $part -match '[\x00-\x1F\x7F]') { return }
        if (@($part -split '/' | Where-Object { $_.Trim() -in '.', '..' }).Count) { return }
    }

    # Graph addresses a named site by hostname-then-path between colons, and the root site
    # of a hostname by hostname alone - the colons are the path syntax, so an empty path
    # must not leave them behind.
    $site = '{0}/sites/{1}' -f $script:GraphRoot, $hostname
    if ($sitePath) { $site = '{0}:{1}' -f $site, $sitePath }

    # Escaped per segment, because a document library is a place people put files with
    # spaces and umlauts in the name, and Graph answers an unescaped one with a 400 that
    # reads like a permission problem. The separators have to survive the escaping.
    $escaped = ($filePath -split '/' | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/'

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.DefinitionAddress'
        Hostname   = $hostname
        SitePath   = $sitePath
        FilePath   = $filePath
        # The site, asked for on its own. Graph's colon syntax addresses one thing by path
        # per request: a site by path and then an item by path in the same URL is two, and
        # it answers 400. So the site is resolved to an id first and the item addressed
        # against that - which is the documented route, and the one that works.
        SiteUri    = $site
        ItemPath   = $escaped
    }
}

function Get-DriveItemUri {
    <#
    .SYNOPSIS
        Where the document lives, once the site has an id. Pure.
    .DESCRIPTION
        The item, not its content. Asking Graph for /content answers with a redirect to a
        pre-authenticated storage URL, and that URL rejects the Authorization header it was
        reached with - so the download URL is read off the item instead.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [Parameter(Mandatory)]$Address
    )

    '{0}/sites/{1}/drive/root:/{2}' -f $script:GraphRoot, $SiteId, $Address.ItemPath
}

function Test-DefinitionFetchConfigured {
    <#
    .SYNOPSIS
        Whether this Run has been told enough to attempt a fetch at all. Pure.
    .DESCRIPTION
        All three are needed and none ships filled in yet. A Run without them is the
        package doing what it is supposed to do until MERLIN's tenant is provisioned.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()][AllowEmptyString()][string]$TenantId,
        [AllowNull()][AllowEmptyString()][string]$ClientId,
        [AllowNull()][AllowEmptyString()][string]$Address
    )

    if (-not $TenantId -or -not "$TenantId".Trim()) { return $false }
    if (-not $ClientId -or -not "$ClientId".Trim()) { return $false }
    [bool](ConvertTo-DefinitionAddress -Address $Address)
}

function Request-DeviceCode {
    <#
    .SYNOPSIS
        Asks Entra for a code the Technician can type on their own device.
    .DESCRIPTION
        Untested against a real tenant: #1 records the device flow as a deliberate gap.
        What the request is made of is tested.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [string]$Scope = $script:GraphScope
    )

    Set-FetchTls

    $response = Invoke-RestMethod -Method Post -ErrorAction Stop `
        -Uri ($script:EntraDeviceCodeFormat -f $TenantId) `
        -Body @{ client_id = $ClientId; scope = $Scope }

    [pscustomobject]@{
        PSTypeName       = 'Gutcheck.DeviceCode'
        DeviceCode       = $response.device_code
        UserCode         = $response.user_code
        VerificationUri  = $response.verification_uri
        IntervalSeconds  = $(if ($response.interval)   { [int]$response.interval }   else { $script:DeviceFlowPollSeconds })
        ExpiresInSeconds = $(if ($response.expires_in) { [int]$response.expires_in } else { $script:DeviceFlowTimeoutSeconds })
    }
}

function Wait-DeviceToken {
    <#
    .SYNOPSIS
        Polls until the Technician has approved, or until time runs out. Returns the
        sign-in: the access token, a refresh token when one was asked for, and how long
        the access token lasts.
    .DESCRIPTION
        Returns $null on refusal, expiry or running out of time rather than throwing: none
        of them is a failure of the Run, only of the fetch.
 
        Only an answer Entra actually gave is allowed to end the wait. A proxy returning
        HTML, a DNS hiccup or a reset connection is unreadable rather than refused, and
        giving up on one would abandon a Technician who has already been told to fetch
        their phone - for a blip that would have cleared before the next poll.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)]$DeviceCode
    )

    Set-FetchTls

    $deadline = (Get-Date).AddSeconds([math]::Min($DeviceCode.ExpiresInSeconds, $script:DeviceFlowTimeoutSeconds))
    $interval = $DeviceCode.IntervalSeconds
    $uri      = $script:EntraTokenFormat -f $TenantId

    while ($true) {
        # Clamped to what is left, so backing off cannot carry a Run past the deadline it
        # told the Technician about.
        $remaining = [int]([math]::Floor(($deadline - (Get-Date)).TotalSeconds))
        if ($remaining -le 0) { return $null }
        Start-Sleep -Seconds ([math]::Max(1, [math]::Min($interval, $remaining)))

        $response = $null
        try {
            $response = Invoke-RestMethod -Method Post -Uri $uri -ErrorAction Stop -Body @{
                client_id   = $ClientId
                device_code = $DeviceCode.DeviceCode
                grant_type  = 'urn:ietf:params:oauth:grant-type:device_code'
            }
        }
        catch {
            # Entra answers a pending approval with HTTP 400 and the reason in the body,
            # which Invoke-RestMethod raises rather than returns.
            $response = Read-EntraErrorBody -ErrorRecord $_
        }

        if ($response.access_token) { return ConvertTo-SignIn -Response $response }

        switch ("$($response.error)") {
            # Still waiting for the Technician.
            'authorization_pending' { continue }
            # A request, not a refusal: ignoring it gets the poll throttled.
            'slow_down' {
                $interval = [math]::Min($interval + $script:DeviceFlowSlowDownSeconds,
                                        $script:DeviceFlowMaxPollSeconds)
                continue
            }
            # Nothing readable came back. Not an answer, so not a reason to stop.
            '' { continue }
            # Declined, expired, a bad code. Polling on would keep a Technician waiting
            # for an answer that has already been given.
            default { return $null }
        }
    }
}

function ConvertTo-SignIn {
    <#
    .SYNOPSIS
        Entra's token response as the sign-in a Run carries. Pure.
    .DESCRIPTION
        One shape for the device flow and for renewing, because a kept sign-in is saved
        the same way whichever of the two produced it. expires_in is read rather than
        assumed; the assumption is only the fallback when Entra did not say.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Response)

    [pscustomobject]@{
        PSTypeName       = 'Gutcheck.SignIn'
        AccessToken      = [string]$Response.access_token
        RefreshToken     = $(if ($Response.refresh_token) { [string]$Response.refresh_token } else { $null })
        ExpiresInSeconds = $(if ($Response.expires_in) { [int]$Response.expires_in } else { $script:FetchTokenAssumedLifetimeSeconds })
    }
}

function Request-RefreshedSignIn {
    <#
    .SYNOPSIS
        Renews a kept sign-in with its refresh token. Says whether Entra renewed it,
        refused it, or could not be reached.
    .DESCRIPTION
        Nobody is asked anything: this is the point of having said yes. Asks for the same
        permission the sign-in was kept with - Entra issues nothing wider than it was
        given - and returns the new refresh token as well, because Entra rotates them and
        the old one stops working.
 
        Refused and Unreachable are different answers and the caller treats them
        differently. Refused - Entra answered with a reason: revoked, expired on its side,
        the Technician's password changed - means the kept sign-in is finished. Unreachable
        - nothing readable came back: DNS, a proxy's HTML, a reset connection - means
        nothing was decided, and a kept sign-in must not be thrown away over a blip. Never
        throws either way.
    .OUTPUTS
        Status ('Renewed', 'Refused' or 'Unreachable'), and SignIn when renewed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$RefreshToken,
        [Parameter(Mandatory)][string]$Scope
    )

    Set-FetchTls

    try {
        $response = Invoke-RestMethod -Method Post -Uri ($script:EntraTokenFormat -f $TenantId) -ErrorAction Stop -Body @{
            client_id     = $ClientId
            grant_type    = 'refresh_token'
            refresh_token = $RefreshToken
            scope         = $Scope
        }
    }
    catch {
        # Entra refuses with a 400 and a JSON body naming the reason; anything without one
        # is not an answer from Entra. The same reading Wait-DeviceToken relies on.
        $body   = Read-EntraErrorBody -ErrorRecord $_
        $status = $(if ($body -and $body.error) { 'Refused' } else { 'Unreachable' })
        Write-Verbose ('Could not renew the kept sign-in ({0}): {1}' -f $status, $_.Exception.Message)
        return [pscustomobject]@{ Status = $status; SignIn = $null }
    }

    if (-not $response.access_token) { return [pscustomobject]@{ Status = 'Refused'; SignIn = $null } }
    [pscustomobject]@{ Status = 'Renewed'; SignIn = (ConvertTo-SignIn -Response $response) }
}

function Read-EntraErrorBody {
    <#
    .SYNOPSIS
        The JSON body Entra sent with a failed request, or $null if there was none.
    .DESCRIPTION
        The device flow signals "not yet" with a 400, so the body of a failed request is
        where the answer usually is. 5.1 and 7 surface it differently, which is the whole
        reason this is one function rather than two lines at the call site.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$ErrorRecord)

    $body = $null

    # PowerShell 7 keeps the body on the error record.
    if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
        $body = $ErrorRecord.ErrorDetails.Message
    }
    # Windows PowerShell 5.1 leaves it on the response stream.
    elseif ($ErrorRecord.Exception.Response) {
        try {
            $stream = $ErrorRecord.Exception.Response.GetResponseStream()
            $reader = New-Object IO.StreamReader($stream)
            try { $body = $reader.ReadToEnd() } finally { $reader.Dispose() }
        }
        catch { }
    }

    if (-not $body) { return $null }
    # A proxy or a gateway answers with HTML, which is not an answer from Entra.
    try { $body | ConvertFrom-Json } catch { $null }
}

function Test-DefinitionRequest {
    <#
    .SYNOPSIS
        Whether a Graph request is one a fetch of this document may send its token to. Pure.
    .DESCRIPTION
        The code half of the boundary. The permission a sign-in asks for decides what a
        token could open; this decides where Gutcheck ever sends it, and it allows exactly
        two things: the site the address names, and the one document in that site's
        library. Not /me, not search, not another site, not the library's listing, not the
        README beside the document. A bug, or a later change that grew a request, cannot
        carry the token past this even where the tenant granted too much.
 
        What this cannot do is protect a token somebody copies out of the process: that
        token answers to its scope, not to this code. Sites.Selected is the boundary for
        that; this keeps Gutcheck itself inside it.
 
        The document request is only allowed once the site has answered with an id, and
        only if that id is a SharePoint site id on the address's own host. A site id is
        spliced into a URL, so one that carried a slash or a dot-dot would walk the
        request somewhere else while still looking derived.
    .PARAMETER SiteId
        The id the site answered with. Without it, nothing but the site itself is allowed.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Uri,
        [Parameter(Mandatory)]$Address,
        [AllowNull()][AllowEmptyString()][string]$SiteId
    )

    if (-not $Uri -or -not $Address) { return $false }

    # The URL that is checked has to be the URL that is sent. .NET normalises a URL
    # before a request leaves - dot segments collapse, escapes are rewritten - so a string
    # that compares equal here can still arrive somewhere else. A URL normalisation would
    # change is refused outright, whatever built it; ConvertTo-DefinitionAddress stops the
    # known ways in, and this stops the ones nobody has thought of yet.
    $parsed = $null
    if (-not [Uri]::TryCreate($Uri, [UriKind]::Absolute, [ref]$parsed)) { return $false }
    if ($parsed.AbsoluteUri -cne $Uri) { return $false }
    if ($parsed.Scheme -ne 'https' -or $parsed.Host -ne 'graph.microsoft.com') { return $false }
    if (-not $parsed.AbsolutePath.StartsWith('/v1.0/sites/')) { return $false }

    if ($Uri -ceq $Address.SiteUri) { return $true }

    if (-not $SiteId) { return $false }

    # <host>,<site collection guid>,<web guid> - and nothing that could change a path.
    # \z rather than $: in .NET, $ also matches just before a final line break.
    $siteIdShape = '\A{0},[0-9A-Fa-f-]+,[0-9A-Fa-f-]+\z' -f [regex]::Escape($Address.Hostname)
    if ($SiteId -notmatch $siteIdShape) { return $false }

    $Uri -ceq (Get-DriveItemUri -SiteId $SiteId -Address $Address)
}

function Invoke-GutcheckGraphRequest {
    <#
    .SYNOPSIS
        One Graph GET, with Graph's own explanation when it refuses. Untested: it needs a
        tenant.
    .DESCRIPTION
        Not called Invoke-GraphRequest, which it was: Microsoft.Graph.Authentication
        exports that name as an alias for Invoke-MgGraphRequest, and PowerShell resolves
        an alias before a function - even inside this module. In any session where the
        Graph SDK was loaded, which is every session that just ran the provisioning or
        publishing scripts, every fetch called the SDK instead and fell back to the Local
        Definitions. Module.Tests.ps1 guards every name in the module against the same.
 
 
        Graph says exactly what is wrong in the body of a failed request - a site that does
        not exist, a path it will not parse, a permission the Technician has not been
        granted - and every one of those is a different thing for somebody to go and fix.
        Left alone, PowerShell reports "Response status code does not indicate success:
        400 (Bad Request)", which is the same sentence for all of them and sends whoever
        reads it looking in the wrong place.
 
        This is the only place the token leaves the module, so this is where the boundary
        is checked - not by the caller, where the next caller would forget. A request
        Test-DefinitionRequest does not allow is never sent.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Uri,
        [Parameter(Mandatory)][string]$Token,
        [Parameter(Mandatory)][string]$Describing,
        [Parameter(Mandatory)]$Address,
        [AllowNull()][AllowEmptyString()][string]$SiteId
    )

    if (-not (Test-DefinitionRequest -Uri $Uri -Address $Address -SiteId $SiteId)) {
        throw ('Refused to send the sign-in to {0}: a fetch reads only {1} and its document {2}.' -f
            $Uri, $Address.SiteUri, $Address.FilePath)
    }

    # Set here as well as by the caller. It is idempotent, and a function that makes an
    # HTTPS call has to be safe to call on its own rather than safe only in the order it
    # happens to be called in today.
    Set-FetchTls

    try {
        # -UseBasicParsing because Windows PowerShell 5.1 otherwise wants the Internet
        # Explorer engine, which is absent or un-initialised on plenty of Target Machines.
        $response = Invoke-WebRequest -Method Get -Uri $Uri -UseBasicParsing -ErrorAction Stop -Headers @{
            Authorization = "Bearer $Token"
            Accept        = 'application/json'
        }
    }
    catch {
        $detail = Get-GraphErrorText -ErrorRecord $_
        if ($detail) { throw ('{0}: {1}' -f $Describing, $detail) }
        throw ('{0}: {1}' -f $Describing, $_.Exception.Message)
    }

    ConvertTo-ResponseText -Content $response.Content | ConvertFrom-Json
}

function Get-GraphErrorText {
    <#
    .SYNOPSIS
        What Graph said went wrong, as one line, or $null if it did not say.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$ErrorRecord)

    # Graph fails in the same shape Entra does - a JSON body on a non-2xx - so the reading
    # of it is already solved; only the shape inside differs.
    $body = Read-EntraErrorBody -ErrorRecord $ErrorRecord
    if (-not $body -or -not $body.error) { return $null }

    $code    = "$($body.error.code)".Trim()
    $message = "$($body.error.message)".Trim()

    if ($code -and $message) { return '{0} - {1}' -f $code, $message }
    if ($message) { return $message }
    if ($code) { return $code }
    $null
}

function Get-PublishedDefinition {
    <#
    .SYNOPSIS
        Reads the Published Definitions out of SharePoint. Untested: it needs a tenant.
    .DESCRIPTION
        Two requests, deliberately. Graph's /content answers with a redirect to a
        pre-authenticated storage URL, and that URL rejects the Authorization header it was
        reached with - so the item is read first, its download URL taken off it, and the
        document fetched clean.
 
        Invoke-WebRequest and not Invoke-RestMethod: the latter deserialises a JSON
        response into objects, and a Check Definition document is JSON, so the raw text
        this function exists to return would come back as an object and stringify to
        "@{...}". That shipped broken against GitHub for a day.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Address,
        [Parameter(Mandatory)][string]$Token
    )

    Set-FetchTls

    # The site first, for its id. See ConvertTo-DefinitionAddress: Graph will not take a
    # site by path and an item by path in one URL.
    $site = Invoke-GutcheckGraphRequest -Uri $Address.SiteUri -Token $Token -Describing 'the site' -Address $Address
    if (-not $site.id) {
        throw ('SharePoint answered for {0} without a site id.' -f $Address.SiteUri)
    }

    $described   = Invoke-GutcheckGraphRequest -Token $Token -Describing $Address.FilePath `
        -Uri (Get-DriveItemUri -SiteId $site.id -Address $Address) -Address $Address -SiteId $site.id
    $downloadUri = $described.'@microsoft.graph.downloadUrl'
    if (-not $downloadUri) {
        throw ('SharePoint answered for {0} without a download URL.' -f $Address.FilePath)
    }

    # No Authorization header: the download URL carries its own, and sending the token
    # alongside it is what makes storage answer 401.
    $content = Invoke-WebRequest -Method Get -Uri $downloadUri -UseBasicParsing -ErrorAction Stop

    ConvertFrom-CheckDefinitionJson -Document (ConvertTo-ResponseText -Content $content.Content)
}

function ConvertTo-ResponseText {
    <#
    .SYNOPSIS
        A response body as text. 5.1 hands back bytes for some content types; 7 does not.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Content)

    if ($Content -is [byte[]]) { return [Text.Encoding]::UTF8.GetString($Content) }
    [string]$Content
}

function Invoke-DefinitionFetch {
    <#
    .SYNOPSIS
        The whole fetch: authenticate, read, parse. Returns $null when it cannot.
    .DESCRIPTION
        Every way this can fail ends in $null and a Run on Local Definitions, because a
        Technician standing at a Customer Site with a broken machine needs a Report more
        than they need this to have worked. Which set produced the Report is what
        Provenance is for, and it is stated either way.
 
        A sign-in is found before one is asked for: this session's, or one kept on this
        machine - renewed with its refresh token if its access token has run out. Only
        when there is none does a Run ask the Technician whether they will come back, and
        then sign in. The question comes first because what is asked of Entra depends on
        the answer: a sign-in to be kept asks for a refresh token as well.
 
        Nothing is written down unless the answer was yes. See Private/TokenCache.ps1.
    .PARAMETER KeepSignIn
        The answer to the question, given in advance: $true keeps the sign-in on this
        machine for seven days, $false keeps nothing, $null (the default) asks - or, with
        nobody there to ask, keeps nothing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyString()][string]$TenantId,
        [AllowNull()][AllowEmptyString()][string]$ClientId,
        [AllowNull()][AllowEmptyString()][string]$Address,
        [AllowNull()][Nullable[bool]]$KeepSignIn
    )

    # Whether the Technician named a document themselves decides how a bad one is
    # reported: silence is right for a package nobody has configured, and wrong for a
    # Technician who typed something and is owed the reason it did not work.
    $named = [bool]("$Address".Trim())

    # Naming one thing overrides only that one, so pointing a Run at a second document
    # needs only -PublishedDefinitions.
    if (-not $TenantId) { $TenantId = $script:DefaultTenantId }
    if (-not $ClientId) { $ClientId = $script:DefaultClientId }
    if (-not $Address)  { $Address  = $script:DefaultDefinitionAddress }

    if (-not (Test-DefinitionFetchConfigured -TenantId $TenantId -ClientId $ClientId -Address $Address)) {
        if ($named -and -not (ConvertTo-DefinitionAddress -Address $Address)) {
            Write-Host (Get-Text 'Console.Fetch.Heading') -ForegroundColor Cyan
            Write-Host (Get-Text 'Console.Fetch.BadAddress' $Address) -ForegroundColor Yellow
            Write-Host (Get-Text 'Console.Fetch.UsingLocal') -ForegroundColor Gray
        }
        return $null
    }

    try {
        # A sign-in this session already made, or one kept on this machine on purpose. The
        # key covers the document as well as the tenant, so pointing a Run somewhere else
        # signs in again rather than reading one library with the other's token.
        # Whatever is past its week comes off this machine first, on every Run - the
        # seven days are only true if they are enforced on the disk, not just refused here.
        Remove-ExpiredKeptSignIn

        $cacheKey = Get-TokenCacheKey -TenantId $TenantId -ClientId $ClientId -Address $Address
        $cached   = Get-CachedFetchToken -Key $cacheKey

        Write-Host (Get-Text 'Console.Fetch.Heading') -ForegroundColor Cyan

        # A kept sign-in whose access token has run out is renewed, not asked for again:
        # that is what the Technician's yes was for. Renewing keeps the original
        # KeptUntil, so the week runs from the answer and not from the last Run.
        if ($cached -and -not $cached.Token -and $cached.RefreshToken) {
            $renewal = Request-RefreshedSignIn -TenantId $TenantId -ClientId $ClientId `
                -RefreshToken $cached.RefreshToken -Scope (Get-SignInScope -Keep $true)

            switch ($renewal.Status) {
                'Renewed' {
                    $signIn  = $renewal.SignIn
                    $refresh = $(if ($signIn.RefreshToken) { $signIn.RefreshToken } else { $cached.RefreshToken })
                    Save-FetchToken -Key $cacheKey -Token $signIn.AccessToken -ExpiresInSeconds $signIn.ExpiresInSeconds `
                        -RefreshToken $refresh -KeptUntil $cached.KeptUntil
                    $cached = [pscustomobject]@{ Token = $signIn.AccessToken; Source = $cached.Source; KeptUntil = $cached.KeptUntil }
                }
                'Unreachable' {
                    # Nothing was decided, so nothing is thrown away. This Run could not
                    # have fetched either - Entra is where the fetch starts - so it falls
                    # back, and the kept sign-in is there for the next one.
                    Write-Host (Get-Text 'Console.Fetch.SignInServiceUnreachable') -ForegroundColor Gray
                    return $null
                }
                default {
                    Write-Host (Get-Text 'Console.Fetch.SignInExpired') -ForegroundColor Gray
                    Remove-CachedFetchToken -Key $cacheKey
                    $cached = $null
                }
            }
        }

        if ($cached -and $cached.Token) {
            # Which sign-in is being reused, said plainly. One read off this machine is
            # said every time, with how long it stays and how to remove it, rather than
            # only on the Run that kept it.
            if ($cached.Source -eq 'File') {
                Write-Host (Get-Text 'Console.Fetch.UsingKeptSignIn' (Format-KeptUntil -KeptUntil $cached.KeptUntil)) -ForegroundColor Yellow
            }
            else { Write-Host (Get-Text 'Console.Fetch.AlreadySignedIn') -ForegroundColor Gray }

            try {
                return Get-PublishedDefinition -Address (ConvertTo-DefinitionAddress -Address $Address) -Token $cached.Token
            }
            catch {
                # A remembered token that Graph will not take is worse than none: every
                # Run would offer the same refused token and fall back to Local
                # Definitions without anybody being asked to sign in. The Run that meets
                # it drops it and signs in properly, which is why this falls through.
                Write-Host (Get-Text 'Console.Fetch.SignInExpired') -ForegroundColor Gray
                Remove-CachedFetchToken -Key $cacheKey
            }
        }

        # No usable sign-in, so one is about to be asked for - and whether it is kept is
        # asked first, while the Technician is still watching, because the answer decides
        # what is asked of Entra.
        $keep = Resolve-KeepSignIn -Requested $KeepSignIn -Interactive (Test-RunInteractive)
        if ($null -eq $keep) { $keep = Read-KeepSignIn }

        $deviceCode = Request-DeviceCode -TenantId $TenantId -ClientId $ClientId -Scope (Get-SignInScope -Keep $keep)

        # The camera is the part worth saving. Entra offers no way to carry the code in the
        # address, so the code still has to be typed either way - but scanning the square
        # beats reading a URL off a Customer's screen and typing it on a phone.
        if (Show-DeviceLoginQr -VerificationUri $deviceCode.VerificationUri) {
            Write-Host (Get-Text 'Console.Fetch.ScanOrOpen' $deviceCode.VerificationUri) -ForegroundColor Gray
            Write-Host (Get-Text 'Console.Fetch.ThenEnter' $deviceCode.UserCode) -ForegroundColor Yellow
        }
        else {
            Write-Host (Get-Text 'Console.Fetch.OpenOnPhone' $deviceCode.VerificationUri $deviceCode.UserCode) `
                -ForegroundColor Yellow
        }
        # The promise has to match what the Run will actually do. "Nothing is left behind"
        # is said only when it is true; "kept until" waits until the sign-in has arrived
        # and can actually be kept.
        if (-not $keep) { Write-Host (Get-Text 'Console.Fetch.NothingLeft') -ForegroundColor Gray }

        $signIn = Wait-DeviceToken -TenantId $TenantId -ClientId $ClientId -DeviceCode $deviceCode
        if (-not $signIn -or -not $signIn.AccessToken) {
            Write-Host (Get-Text 'Console.Fetch.NotSignedIn') -ForegroundColor Gray
            return $null
        }

        # A week needs a refresh token to last it. Without one there is only an hour-long
        # access token - nothing worth writing down, and a week promised would be an hour
        # delivered - so the Technician is told, and nothing is kept.
        $keptUntil = $null
        if ($keep -and $signIn.RefreshToken) {
            $keptUntil = Get-KeptUntil
            Write-Host (Get-Text 'Console.Fetch.KeepingSignIn' (Format-KeptUntil -KeptUntil $keptUntil)) -ForegroundColor Yellow
        }
        elseif ($keep) { Write-Host (Get-Text 'Console.Fetch.CouldNotKeep') -ForegroundColor Yellow }

        # Remembered before the read, so a fetch that fails on SharePoint's side does not
        # also throw away a sign-in the Technician just completed. Kept on this machine
        # only when it could be; a keep-until date is what puts it on disk.
        Save-FetchToken -Key $cacheKey -Token $signIn.AccessToken -ExpiresInSeconds $signIn.ExpiresInSeconds `
            -RefreshToken $signIn.RefreshToken -KeptUntil $keptUntil

        Get-PublishedDefinition -Address (ConvertTo-DefinitionAddress -Address $Address) -Token $signIn.AccessToken
    }
    catch {
        Write-Host (Get-Text 'Console.Fetch.Unreachable' $_.Exception.Message) `
            -ForegroundColor Gray
        $null
    }
}