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