Private/Kinds/OutlookAuth.ps1

# The OutlookAuth Kind: whether Outlook can sign in, to Microsoft 365 or to Exchange on premises.
#
# "Outlook keeps asking for my password" is, on a Microsoft 365 mailbox, almost always
# one of a handful of things on the machine rather than in the tenant: modern
# authentication (ADAL) or the Windows sign-in broker (WAM) switched off by a registry
# value somebody set years ago, MAPI over HTTP switched off so Outlook falls back to a
# transport and a prompt Exchange Online no longer accepts, the broker's AppX package
# missing or broken, or TLS 1.2 switched off for clients. Each of those is sourced in
# docs/research/outlook-connectivity-checks.md, checks 4 and 8.
#
# What this Gatherer reads, all of it without admin rights:
#
# - HKCU\Software\Microsoft\Office\16.0\Common\Identity: EnableADAL,
# DisableADALatopWAMOverride, DisableAADWAM, DisableMSAWAM
# - HKCU\Software\Microsoft\Exchange: MapiHttpDisabled (KB 2937684) and
# AlwaysUseMSOAuthForAutoDiscover (KB 3126599, reported and never graded)
# - each of those under HKCU\Software\Policies as well, where Group Policy writes; the
# policy value wins and the row says which one it was. The research sources the
# preference paths only; the policy paths are the usual Office mirror of them.
# - HKLM\...\SCHANNEL\Protocols\TLS 1.2\Client: Enabled, DisabledByDefault
# - Get-AppxPackage for the current user: Microsoft.AAD.BrokerPlugin and
# Microsoft.Windows.CloudExperienceHost, with version and status
# - over Days: Microsoft-Windows-AAD/Operational 1098, 1081, 1084, 1088, and the
# Application log's Microsoft-Windows-AppModel-State errors and repairs naming the
# broker's package family
# - whether the process is elevated, and who it runs as against who is logged on
#
# Event payloads are read by position, never by message text, for the reason
# Private/Kinds/Stability.ps1 gives. The templates, read off this machine's manifests with
# Get-WinEvent -ListProvider (German Windows 11 26200, 2026-09-25):
#
# Microsoft-Windows-AAD 1097/1098 Error (UInt32, shown hex), ErrorMessage,
# AdditionalInformation
# 1081/1088 Error (string, e.g. invalid_grant), ErrorDescription
# (carries the AADSTS number), [1081: CorrelationID]
# 1084 Result (HRESULT), Target
# Microsoft-Windows-AppModel-State (Application log)
# 10-12 ErrorString (package), Error
# 13 FolderString, PackageString, Error
# 20/21/23/24 Operation, PackageFamily, OperationError, ...
# 22 FolderPath, Error
#
# Only the error code is kept - and from 1081/1088 the AADSTS number out of the
# description - never the message: the messages are German here, and some of them name
# the account.
#
# All of this is the user's own state: HKCU, the per-user AppX registration, the broker
# that runs in the user's session. An elevated Run, or one running as somebody other than
# the logged-on user, reads another profile and would report it confidently, so the Judge
# says it could not look instead (research, check 4: "must run in the user's session").
#
# Where the mailbox is decides what is graded. The Gatherer declares -Observed and copies
# the Autodiscover Check's per-domain answer across (Private/Kinds/Autodiscover.ps1
# documents the shape), with that Check's ExchangeOnline pin; the Judge classifies it with
# ConvertTo-AutodiscoverMailboxLocation, the one classifier, honouring that pin, unless
# MailboxLocation here pins it over everything.
#
# On-prem sign-in prerequisites (research, check 9). These explain the password prompts
# that start after an Exchange update, and are graded only when the mailbox is not in
# Exchange Online:
#
# - HKLM\SYSTEM\CurrentControlSet\Control\Lsa: LmCompatibilityLevel, as the Settings row
# Lsa\LmCompatibilityLevel. Machine-wide and readable unelevated. Exchange's Extended
# Protection, on by default since Exchange 2019 CU14, refuses NTLMv1, and Microsoft says
# the level "must be set at least to a value of 3"; absent, Windows treats it as 3. The
# row keeps absent (Value $null) apart from any number set, and both apart from a key
# that could not be read (Error set), which is nobody's default.
# - the user's Windows credential store, through cmdkey /list: the target names of the
# MicrosoftOffice1x_Data:SSPI entries Outlook cached for RPC over HTTP and keeps
# offering after a switch to MAPI over HTTP (KB 4051374). Only those names are kept -
# every other entry is someone's unrelated account - and cmdkey never shows a secret.
# Its output is localised ("Ziel:", "Typ:" here), so ConvertFrom-OutlookAuthCmdkeyList
# reads it by position: the first line of each indented block is the target. Observed
# on this machine (German Windows 11 26200, 2026-09-25): blocks of three or four lines,
# separated by empty lines or by lines of four spaces, the persistence line sometimes
# missing.
#
# The Entra device and its sign-in token (research, check 7), graded whatever the mailbox
# location, because the device's sign-in serves every Microsoft 365 program, not Outlook
# alone:
#
# - dsregcmd /status, run as whoever runs the Gatherer. Its device part (join state,
# DeviceAuthStatus) is machine-wide; its User State and SSO State are the user's, so
# read from an elevated or foreign session they are somebody else's, and the Judge
# says so instead of grading them (Microsoft: "The command must run in a user context").
# - ConvertFrom-OutlookAuthDsregStatus reads the output by structure: a section is the
# "| Title |" line between two "+----+" banners, a value is an indented "Key : Value"
# line split at its first colon. Observed on this machine (German Windows 11 26200,
# 2026-09-25): keys, section titles and values all came out in English, as in
# Microsoft's documented samples, so the Judge reads the keys by name - they are
# dsregcmd's tokens, not translated labels - and the section titles only label rows.
# - Select-OutlookAuthDsregValue keeps only the keys the Judge and the Technician need:
# no device or tenant IDs, no user name, and of the server's error message only its
# AADSTS number.
# - The times are UTC in dsregcmd's own "yyyy-MM-dd HH:mm:ss.fff UTC"; the Gatherer
# records when it read them (ReadAt, UTC), so the Judge can age the token without a
# clock of its own.

$script:OutlookAuthIdentityKey       = 'Software\Microsoft\Office\16.0\Common\Identity'
$script:OutlookAuthIdentityValues    = @('EnableADAL', 'DisableADALatopWAMOverride', 'DisableAADWAM', 'DisableMSAWAM')
$script:OutlookAuthExchangeKey       = 'Software\Microsoft\Exchange'
$script:OutlookAuthExchangeValues    = @('MapiHttpDisabled', 'AlwaysUseMSOAuthForAutoDiscover')
$script:OutlookAuthSchannelKey       = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'
$script:OutlookAuthLsaKey            = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'

# The saved credentials kept, by target name: KB 4051374 names MicrosoftOffice15_Data:SSPI
# and MicrosoftOffice16_Data:SSPI.
$script:OutlookAuthSspiPattern       = '^MicrosoftOffice1\d_Data:SSPI'

# What Windows does when LmCompatibilityLevel is absent (research, check 9).
$script:OutlookAuthLmDefault         = 3

$script:OutlookAuthBrokerPackages    = @('Microsoft.AAD.BrokerPlugin', 'Microsoft.Windows.CloudExperienceHost')

$script:OutlookAuthAadLog            = 'Microsoft-Windows-AAD/Operational'
$script:OutlookAuthAadIds            = @(1098, 1081, 1084, 1088)

# The AppModel-State events in the Application log, by id: the payload position of the
# package (or folder) and of the error code. See the file header.
$script:OutlookAuthAppModelProvider  = 'Microsoft-Windows-AppModel-State'
$script:OutlookAuthAppModelFields    = @{
    10 = @(0, 1); 11 = @(0, 1); 12 = @(0, 1); 13 = @(1, 2); 22 = @(0, 1)
    20 = @(1, 2); 21 = @(1, 2); 23 = @(1, 2); 24 = @(1, 2)
}

# Which AAD events a Judge counts as sign-in errors. 1097/1098 are the broker reporting
# that a silent token request needs the user - routine whenever MFA wants an interaction,
# hundreds a week on a healthy machine - so they are evidence, not a Severity (research,
# check 4, Inference). 1081 (the service refused), 1084 (network) and 1088 (WS-Trust) are.
$script:OutlookAuthAadGradedIds      = @(1081, 1084, 1088)

# The dsregcmd /status keys kept, from the research's list (check 7) and Microsoft's
# dsregcmd page: the join state, the device's health in Entra ID, the user's token and
# the codes of its last failed attempt, and the context dsregcmd itself says it ran in.
$script:OutlookAuthDsregKeys         = @(
    'AzureAdJoined', 'EnterpriseJoined', 'DomainJoined', 'DeviceAuthStatus'
    'WorkplaceJoined', 'WamDefaultSet'
    'AzureAdPrt', 'AzureAdPrtUpdateTime', 'AzureAdPrtExpiryTime'
    'Attempt Status', 'Server Error Code', 'Server Error Description'
    'User Context'
)

# The failed-attempt diagnostics dsregcmd prints after the PRT they belong to: after
# AzureAdPrt for Entra ID's, after EnterprisePrt for AD FS's (Microsoft's dsregcmd page).
$script:OutlookAuthDsregPrtDiagnostics = @('Attempt Status', 'Server Error Code', 'Server Error Description')

function Get-OutlookAuthData {
    <#
    .PARAMETER Observed
        What earlier Checks gathered. Only the Autodiscover Check's domains and its pin are read.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [hashtable]$Parameters = @{},
        [AllowNull()][hashtable]$Observed = @{}
    )

    $days  = [int](Get-Parameter $Parameters 'Days' 7)
    $since = (Get-Date).AddDays(-$days)

    $settings = @(
        Get-OutlookAuthRegistryValue -Group 'Identity' -Name $script:OutlookAuthIdentityValues -Location @(
            @{ Source = 'Policy';     Path = 'HKCU:\Software\Policies\' + $script:OutlookAuthIdentityKey.Substring('Software\'.Length) }
            @{ Source = 'Preference'; Path = 'HKCU:\' + $script:OutlookAuthIdentityKey })
        Get-OutlookAuthRegistryValue -Group 'Exchange' -Name $script:OutlookAuthExchangeValues -Location @(
            @{ Source = 'Policy';     Path = 'HKCU:\Software\Policies\' + $script:OutlookAuthExchangeKey.Substring('Software\'.Length) }
            @{ Source = 'Preference'; Path = 'HKCU:\' + $script:OutlookAuthExchangeKey })
        Get-OutlookAuthRegistryValue -Group 'Schannel' -Name @('Enabled', 'DisabledByDefault') -Location @(
            @{ Source = 'Machine'; Path = $script:OutlookAuthSchannelKey })
        # Where the Group Policy "LAN Manager authentication level" writes too, so one row.
        Get-OutlookAuthRegistryValue -Group 'Lsa' -Name @('LmCompatibilityLevel') -Location @(
            @{ Source = 'Machine'; Path = $script:OutlookAuthLsaKey })
    )

    # The user's own store: cmdkey lists the credentials of whoever runs it. Only the
    # Office SSPI target names leave this block (see the file header).
    $credentials      = @()
    $credentialTotal  = $null
    $credentialsError = $null
    try {
        $listed = @(& cmdkey.exe /list 2>$null)
        if ($LASTEXITCODE -ne 0) { $credentialsError = 'cmdkey /list: exit code {0}' -f $LASTEXITCODE }
        else {
            $targets         = @(ConvertFrom-OutlookAuthCmdkeyList -Line $listed)
            $credentialTotal = $targets.Count
            $credentials     = @($targets | Where-Object { $_ -match $script:OutlookAuthSspiPattern })
        }
    }
    catch { $credentialsError = $_.Exception.Message }

    # The current user's registrations: the broker is installed per user profile, and a
    # package another profile has is no help to this one.
    $packages      = @()
    $packagesError = $null
    try {
        $packages = @(foreach ($name in $script:OutlookAuthBrokerPackages) {
            $found = @(Get-AppxPackage -Name $name -ErrorAction Stop)
            if (-not $found.Count) {
                [pscustomobject]@{ Name = $name; Present = $false; Version = $null; Status = $null }
                continue
            }
            foreach ($p in $found) {
                [pscustomobject]@{ Name = $name; Present = $true; Version = "$($p.Version)"; Status = "$($p.Status)" }
            }
        })
    }
    catch { $packagesError = $_.Exception.Message }

    $unreadable = @{}

    $aadEvents = @(Get-StabilityEvent -Log $script:OutlookAuthAadLog -Id $script:OutlookAuthAadIds -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Id   = $_.Id
                Time = $_.TimeCreated
                Code = ConvertTo-OutlookAuthErrorCode -Value (Get-EventPayload -LogEntry $_ -Index 0) `
                    -Description $(if ($_.Id -eq 1081 -or $_.Id -eq 1088) { Get-EventPayload -LogEntry $_ -Index 1 })
            }
        })

    $brokerEvents = @(Get-StabilityEvent -Log 'Application' -Provider @($script:OutlookAuthAppModelProvider) `
            -Id @($script:OutlookAuthAppModelFields.Keys) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            $fields  = $script:OutlookAuthAppModelFields[[int]$_.Id]
            $package = Get-EventPayload -LogEntry $_ -Index $fields[0]
            # Only the broker's own entries: the provider logs for every packaged app.
            if ("$package" -notmatch [regex]::Escape($script:OutlookAuthBrokerPackages[0])) { return }
            [pscustomobject]@{
                Id   = $_.Id
                Time = $_.TimeCreated
                Code = ConvertTo-OutlookAuthErrorCode -Value (Get-EventPayload -LogEntry $_ -Index $fields[1])
            }
        })

    # Run as the user, like everything above: its user half is whoever runs it.
    $deviceValues = @()
    $deviceError  = $null
    try {
        $status = @(& dsregcmd.exe /status 2>$null)
        if ($LASTEXITCODE -ne 0) { $deviceError = 'dsregcmd /status: exit code {0}' -f $LASTEXITCODE }
        else { $deviceValues = @(Select-OutlookAuthDsregValue -Row @(ConvertFrom-OutlookAuthDsregStatus -Line $status)) }
    }
    catch { $deviceError = $_.Exception.Message }
    $deviceReadAt = (Get-Date).ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture)

    $autodiscover = $null
    if ($Observed -and $Observed.ContainsKey('Autodiscover')) { $autodiscover = $Observed['Autodiscover'] }

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.OutlookAuth'
        Days           = $days
        IsElevated     = (Get-CurrentPrivilege) -eq 'admin'
        RunningAs      = [Security.Principal.WindowsIdentity]::GetCurrent().Name
        LoggedOnUser   = Get-InteractiveUser
        Settings       = $settings
        Packages       = $packages
        PackagesError  = $packagesError
        AadEvents      = @(Group-OutlookAuthEvent -Event $aadEvents)
        BrokerEvents   = @(Group-OutlookAuthEvent -Event $brokerEvents)
        UnreadableLogs = @($unreadable.Keys | Sort-Object)
        # Target names only. SavedCredentialTotal is how many entries the store listed in
        # all, $null when it was not read: a store with nothing of Office's in it is a
        # different fact from one whose listing came back empty.
        SavedCredentials      = $credentials
        SavedCredentialTotal  = $credentialTotal
        SavedCredentialsError = $credentialsError
        # dsregcmd's keys and values as it printed them, with its section; ReadAt is when,
        # in UTC, so the Judge can age the token dsregcmd dates in UTC.
        DeviceState    = [pscustomobject]@{ ReadAt = $deviceReadAt; Values = $deviceValues; Error = $deviceError }
        # Absent when the Autodiscover Check did not run in this Run, which is a different
        # fact from a mailbox whose location Microsoft would not say.
        Autodiscover   = $(if ($autodiscover) { ConvertTo-OutlookAuthAutodiscoverCopy -Data $autodiscover })
    }
}

function Get-OutlookAuthRegistryValue {
    <#
    .SYNOPSIS
        One row per value name: the value from the first location that sets it, and which.
    .DESCRIPTION
        A location whose key could not be read ends the search: the row carries its Source,
        Path and Error, and no Value. Below an unread policy the preference is not the value
        Office obeys, and taking the absent value for Windows' default would pass NTLMv1 or
        a switched-off TLS as fine (Read-OutlookRegistryValue, in OutlookData.ps1).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Group,
        [Parameter(Mandatory)][string[]]$Name,
        [Parameter(Mandatory)][hashtable[]]$Location
    )

    foreach ($n in $Name) {
        $row = [pscustomobject]@{ Group = $Group; Name = $n; Value = $null; Source = $null; Path = $null; Error = $null }
        foreach ($l in $Location) {
            $read = Read-OutlookRegistryValue -Path $l.Path -Name $n
            if ($read.Error) { $row.Source = $l.Source; $row.Path = $l.Path; $row.Error = $read.Error; break }
            if ($null -ne $read.Value) { $row.Value = $read.Value; $row.Source = $l.Source; $row.Path = $l.Path; break }
        }
        $row
    }
}

function Group-OutlookAuthEvent {
    <#
    .SYNOPSIS
        Events counted per id and code, newest time kept. Hundreds of 1098s a week are
        normal, and the Report needs how many and which, not each one.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()][object[]]$Event = @())

    @($Event | Where-Object { $_ }) | Group-Object Id, Code | ForEach-Object {
        $first = $_.Group[0]
        [pscustomobject]@{
            Id    = [int]$first.Id
            Code  = $first.Code
            Count = $_.Count
            Last  = ($_.Group | Measure-Object Time -Maximum).Maximum
        }
    }
}

function ConvertFrom-OutlookAuthCmdkeyList {
    <#
    .SYNOPSIS
        The target names out of cmdkey /list output, in the order listed. Pure.
    .DESCRIPTION
        Read by position, never by label, because the labels are localised: an entry is a
        block of indented lines, and its first line is the target, "<label>: <target>"
        (German "Ziel:", French "Cible :"). The label holds no colon, the target may, so
        the line is split at the first one. The "LegacyGeneric:target=" style prefix
        cmdkey puts in front says how the entry was stored and is dropped, leaving the
        name Credential Manager shows. The unindented heading and any line without a
        colon (an empty store's message) are not entries.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line = @())

    $inBlock = $false
    foreach ($l in @($Line)) {
        if (-not "$l".Trim()) { $inBlock = $false; continue }
        if ($inBlock) { continue }
        $inBlock = $true
        if ("$l" -notmatch '^\s+[^:]+:\s*(\S.*)$') { continue }
        $target = $Matches[1].Trim() -replace '^[A-Za-z]+:target=', ''
        if ($target) { $target }
    }
}

function ConvertFrom-OutlookAuthDsregStatus {
    <#
    .SYNOPSIS
        dsregcmd /status output as rows of Section, Key and Value, in the order printed. Pure.
    .DESCRIPTION
        Read by structure (see the file header): "| Title |" names the section the lines
        after it belong to, and an indented line with a colon is "Key : Value", split at
        its first colon because values hold colons of their own (times, URLs). Microsoft's
        samples print one key without a space before the colon ("AadRecoveryEnabled: NO"),
        so the spacing is not relied on. Unindented lines - the banners, the closing "For
        more information, please visit https://..." - are never values, nor is anything
        before the first section.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line = @())

    $section = $null
    foreach ($l in @($Line)) {
        $text = "$l"
        if ($text -match '^\s*\|\s*(.*?)\s*\|\s*$') { $section = $Matches[1]; continue }
        if (-not $section) { continue }
        if ($text -notmatch '^\s+([^:\s][^:]*?)\s*:\s?(.*)$') { continue }
        [pscustomobject]@{ Section = $section; Key = $Matches[1]; Value = $Matches[2].Trim() }
    }
}

function Select-OutlookAuthDsregValue {
    <#
    .SYNOPSIS
        The dsregcmd rows the Report may carry, in the order printed. Pure.
    .DESCRIPTION
        Only $script:OutlookAuthDsregKeys. The failed-attempt diagnostics are kept only
        where they follow AzureAdPrt, never EnterprisePrt, whose AD FS token is no
        Microsoft 365 sign-in. The server's error description may carry the account's
        name, so only its AADSTS number stays, which is what Microsoft's documentation is
        searched by.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][object[]]$Row = @())

    $lastPrt = $null
    foreach ($r in @($Row | Where-Object { $_ })) {
        $key = "$($r.Key)"
        if ($key -eq 'AzureAdPrt' -or $key -eq 'EnterprisePrt') { $lastPrt = $key }
        if ($script:OutlookAuthDsregKeys -notcontains $key) { continue }
        if ($script:OutlookAuthDsregPrtDiagnostics -contains $key -and $lastPrt -ne 'AzureAdPrt') { continue }

        $value = "$($r.Value)"
        if ($key -eq 'Server Error Description') {
            $sts = [regex]::Match($value, 'AADSTS\d+')
            if (-not $sts.Success) { continue }
            $value = $sts.Value
        }
        [pscustomobject]@{ Section = "$($r.Section)"; Key = $key; Value = $value }
    }
}

function ConvertFrom-OutlookAuthDsregTime {
    <#
    .SYNOPSIS
        A dsregcmd time ("2026-09-25 05:21:32.000 UTC"), or the Gatherer's ISO 8601 UTC
        time, as a UTC DateTime; $null for anything else. Pure.
    .DESCRIPTION
        Taken apart by pattern, not by DateTime.Parse, so the culture the Run happens in
        (German: dd.MM.yyyy) cannot change the reading. A time without its UTC mark is not
        read: it would be a guess at the zone.
    #>

    [CmdletBinding()]
    [OutputType([datetime])]
    param([AllowNull()][AllowEmptyString()][string]$Text)

    if ("$Text" -notmatch '^\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,7}))?\s*(?:UTC|Z)\s*$') { return $null }
    try {
        $time = [datetime]::new([int]$Matches[1], [int]$Matches[2], [int]$Matches[3],
            [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [DateTimeKind]::Utc)
    }
    catch { return $null }
    if ($Matches[7]) { $time = $time.AddTicks([long]$Matches[7].PadRight(7, '0')) }
    $time
}

function ConvertTo-OutlookAuthAutodiscoverCopy {
    <#
    .SYNOPSIS
        The part of the Autodiscover Check's data the mailbox location is read from.
    .DESCRIPTION
        A copy of what ConvertTo-AutodiscoverMailboxLocation reads, not the whole thing:
        the certificates and DNS answers belong to that Check's own Report entry and would
        only be carried twice.
    #>

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

    [pscustomobject]@{
        CheckName         = Get-DataProperty $Data 'CheckName'
        # That Check's ExchangeOnline parameter (auto, yes, no), so a Customer pinned there
        # is classified here the same way.
        ExchangeOnlinePin = Get-DataProperty $Data 'ExchangeOnlinePin'
        Domains           = @(foreach ($d in (Get-DataCollection $Data 'Domains')) {
            $probe = Get-DataProperty $d 'ExchangeOnline'
            $failure = Get-DataProperty $probe 'Error'
            [pscustomobject]@{
                Domain         = Get-DataProperty $d 'Domain'
                AddressIsUser  = [bool](Get-DataProperty $d 'AddressIsUser')
                ExchangeOnline = $(if ($probe) {
                    [pscustomobject]@{
                        StatusCode = Get-DataProperty $probe 'StatusCode'
                        Location   = Get-DataProperty $probe 'Location'
                        Error      = $(if ($failure) { [pscustomobject]@{ Message = "$(Get-DataProperty $failure 'Message')" } })
                    }
                })
                V2Url          = Get-DataProperty $d 'V2Url'
            }
        })
    }
}

function ConvertTo-OutlookAuthErrorCode {
    <#
    .SYNOPSIS
        An event's error code as a Technician searches for it. Pure.
    .DESCRIPTION
        A number becomes eight hex digits (0xCAA2000C), which is how Microsoft's articles
        and the event viewer write these codes - whether the payload held it signed, as an
        HRESULT, or unsigned. Text stays text (invalid_grant). An AADSTS number in the
        description is appended, because it is the one thing Microsoft's sign-in
        documentation is searchable by; the rest of the description is dropped.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Value,
        [AllowNull()][AllowEmptyString()][string]$Description
    )

    $code = "$Value".Trim()
    $number = 0L
    if ($code -match '^-?\d+$' -and [long]::TryParse($code, [ref]$number)) {
        if ($number -lt 0) { $number += 4294967296L }
        if ($number -ge 0 -and $number -le 4294967295L) { $code = '0x{0:X8}' -f $number }
    }

    $sts = [regex]::Match("$Description", 'AADSTS\d+')
    if ($sts.Success) {
        if ($code) { $code = '{0} {1}' -f $code, $sts.Value } else { $code = $sts.Value }
    }
    if (-not $code) { return $null }
    $code
}

function Select-OutlookAuthMailboxLocation {
    <#
    .SYNOPSIS
        Where the mailbox is, and what that answer rests on. Pure.
    .DESCRIPTION
        MailboxLocation (auto, ExchangeOnline, OnPremises) pins it. Then the Autodiscover
        Check's own ExchangeOnline pin (yes, no), carried in its data. Otherwise the domain
        whose address is the user's own is asked first, because a hybrid answers per
        mailbox and a made-up address can land on the wrong side (see Autodiscover.ps1);
        then any domain Microsoft answered for; else Unknown. Anything but the two pins is
        auto; the Judge names a pin it does not know.
 
        Basis is Parameter, AutodiscoverPin (the Autodiscover Check's pin), Autodiscover
        (Domain says which), NoAutodiscover (that Check did not run in this Run) or
        NoAnswer.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{})

    $pin = "$(Get-Parameter $Parameters 'MailboxLocation' 'auto')".Trim()
    foreach ($known in 'ExchangeOnline', 'OnPremises') {
        if ($pin -eq $known) { return [pscustomobject]@{ Location = $known; Basis = 'Parameter'; Domain = $null } }
    }

    $autodiscover = Get-DataProperty $Data 'Autodiscover'
    if (-not $autodiscover) { return [pscustomobject]@{ Location = 'Unknown'; Basis = 'NoAutodiscover'; Domain = $null } }

    # Classified as the Autodiscover Check classifies it, never a second way, and with that
    # Check's pin. The pin holds for the Customer, so it holds with no domain found too:
    # asked about no domain, the classifier answers the pin or Unknown.
    $classify = @{}
    $adPin    = Get-DataProperty $autodiscover 'ExchangeOnlinePin'
    if ($null -ne $adPin) { $classify = @{ ExchangeOnline = $adPin } }
    $pinned = ConvertTo-AutodiscoverMailboxLocation -Domain $null -Parameters $classify
    if ($pinned -ne 'Unknown') { return [pscustomobject]@{ Location = $pinned; Basis = 'AutodiscoverPin'; Domain = $null } }

    $domains = Get-DataCollection $autodiscover 'Domains'
    $ordered = @(@($domains | Where-Object { Get-DataProperty $_ 'AddressIsUser' }) +
                 @($domains | Where-Object { -not (Get-DataProperty $_ 'AddressIsUser') }))
    foreach ($d in $ordered) {
        $location = ConvertTo-AutodiscoverMailboxLocation -Domain $d -Parameters $classify
        if ($location -ne 'Unknown') {
            return [pscustomobject]@{ Location = $location; Basis = 'Autodiscover'; Domain = "$(Get-DataProperty $d 'Domain')" }
        }
    }
    [pscustomobject]@{ Location = 'Unknown'; Basis = 'NoAnswer'; Domain = $null }
}

function Get-OutlookAuthSetting {
    <#
    .SYNOPSIS
        One gathered registry row, or $null. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)][string]$Group, [Parameter(Mandatory)][string]$Name)

    (Get-DataCollection $Data 'Settings') |
        Where-Object { "$(Get-DataProperty $_ 'Group')" -eq $Group -and "$(Get-DataProperty $_ 'Name')" -eq $Name } |
        Select-Object -First 1
}

function Format-OutlookAuthSetting {
    <#
    .SYNOPSIS
        "EnableADAL = 0 (Richtlinie)": a value as the Technician will look for it. Pure.
    #>

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

    $source = switch ("$(Get-DataProperty $Setting 'Source')") {
        'Policy'     { Get-Text 'Value.OutlookAuth.Source.Policy' }
        'Preference' { Get-Text 'Value.OutlookAuth.Source.Preference' }
        default      { Get-Text 'Value.OutlookAuth.Source.Machine' }
    }
    (Get-Text 'Value.OutlookAuth.Setting') -f (Get-DataProperty $Setting 'Name'), (Get-DataProperty $Setting 'Value'), $source
}

function Format-OutlookAuthUnread {
    <#
    .SYNOPSIS
        "EnableADAL nicht lesbar (HKCU:\...): <error>" for each row that could not be read,
        or nothing. Pure.
    .DESCRIPTION
        A row the Gatherer could not read has no Value, exactly like one that is not set,
        so every Judge that would take an unset value for Windows' or Office's default asks
        here first: unread is INFO, never the default and never OK.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][object[]]$Setting = @())

    foreach ($s in @($Setting | Where-Object { $_ })) {
        $failure = Get-DataProperty $s 'Error'
        if (-not $failure) { continue }
        (Get-Text 'Value.OutlookAuth.SettingUnreadable') -f (Get-DataProperty $s 'Name'), (Get-DataProperty $s 'Path'), $failure
    }
}

function New-OutlookAuthUnreadFinding {
    <#
    .SYNOPSIS
        The INFO Finding for settings that could not be read, or nothing when all were. Pure.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Check, [AllowNull()][AllowEmptyCollection()][object[]]$Setting = @(), [string]$Suffix = '')

    $unread = @(Format-OutlookAuthUnread -Setting $Setting)
    if (-not $unread.Count) { return }
    New-Finding -Category Apps -Check $Check -Severity INFO -Value (($unread -join '; ') + $Suffix) `
        -Hint (Get-Text 'Hint.OutlookAuth.SettingUnread')
}

function Test-OutlookAuthSettingIs {
    <#
    .SYNOPSIS
        Whether a gathered value is set to the given number. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Setting, [Parameter(Mandatory)][double]$Number)

    $value = ConvertTo-Number (Get-DataProperty $Setting 'Value')
    $null -ne $value -and $value -eq $Number
}

function ConvertTo-OutlookAuthFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    $pin = "$(Get-Parameter $Parameters 'MailboxLocation' 'auto')".Trim()
    if (@('auto', 'ExchangeOnline', 'OnPremises') -notcontains $pin) {
        New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.SignIn') -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.InvalidPin') -f $pin) `
            -Hint (Get-Text 'Hint.OutlookAuth.InvalidPin')
    }

    $mailbox   = Select-OutlookAuthMailboxLocation -Data $Data -Parameters $Parameters
    $microsoft = $mailbox.Location -ne 'OnPremises'

    # Everything but TLS and the AAD log is the user's own state, and read from the wrong
    # session it is somebody else's - answered confidently, and most likely clean.
    $running  = "$(Get-DataProperty $Data 'RunningAs')"
    $loggedOn = "$(Get-DataProperty $Data 'LoggedOnUser')"
    $session  = $null
    if (Get-DataProperty $Data 'IsElevated') { $session = Get-Text 'Value.OutlookAuth.Elevated' }
    elseif ($running -and $loggedOn -and $running -ne $loggedOn) {
        $session = (Get-Text 'Value.OutlookAuth.OtherUser') -f $running, $loggedOn
    }

    if ($session) {
        New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.SignIn') -Severity INFO `
            -Value $session -Hint (Get-Text 'Hint.OutlookAuth.UserSession')
    }
    elseif ($microsoft) {
        New-OutlookAuthModernAuthFinding -Data $Data -Mailbox $mailbox
        New-OutlookAuthWamFinding        -Data $Data -Mailbox $mailbox
        New-OutlookAuthBrokerFinding     -Data $Data -Mailbox $mailbox
    }
    if (-not $microsoft) {
        $basis = Get-Text 'Value.OutlookAuth.Basis.Parameter'
        if ($mailbox.Basis -eq 'Autodiscover') { $basis = (Get-Text 'Value.OutlookAuth.Basis.Domain') -f $mailbox.Domain }
        if ($mailbox.Basis -eq 'AutodiscoverPin') {
            $basis = (Get-Text 'Value.OutlookAuth.Basis.AutodiscoverPin') -f (Get-DataProperty (Get-DataProperty $Data 'Autodiscover') 'CheckName')
        }
        New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.Microsoft365') -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.OnPremises') -f $basis) `
            -Hint (Get-Text 'Hint.OutlookAuth.OnPremises')
    }

    if (-not $session) { New-OutlookAuthMapiFinding -Data $Data -Mailbox $mailbox }
    if ($microsoft)    { New-OutlookAuthAadFinding -Data $Data -Mailbox $mailbox -Parameters $Parameters }
    New-OutlookAuthTlsFinding -Data $Data

    # On-prem prerequisites: Exchange Online signs in with tokens, never NTLM, and has no
    # use for SSPI credentials, so a mailbox known to be there gets neither. Unknown gets
    # both, marked as such. The credential store is the user's, like the settings above.
    if ($mailbox.Location -ne 'ExchangeOnline') {
        New-OutlookAuthNtlmFinding -Data $Data -Mailbox $mailbox -Parameters $Parameters
        if (-not $session) { New-OutlookAuthSavedCredentialFinding -Data $Data -Mailbox $mailbox }
    }

    # The device's sign-in serves every Microsoft 365 program, so it is graded wherever the
    # mailbox is. Its user half is the wrong user's outside the user's session, and the
    # session Finding above already says so.
    New-OutlookAuthDeviceFinding -Data $Data -Mailbox $mailbox -Parameters $Parameters -UserUnread ([bool]$session)
}

function Get-OutlookAuthDeviceValue {
    <#
    .SYNOPSIS
        The first value dsregcmd printed for a key, or $null. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$DeviceState, [Parameter(Mandatory)][string]$Key)

    $row = (Get-DataCollection $DeviceState 'Values') | Where-Object { "$(Get-DataProperty $_ 'Key')" -eq $Key } | Select-Object -First 1
    if ($row) { "$(Get-DataProperty $row 'Value')" }
}

function New-OutlookAuthDeviceFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox, [hashtable]$Parameters, [bool]$UserUnread)

    # Microsoft's figure: "If the value of the AzureAdPrtUpdateTime field is more than four
    # hours, a problem is likely preventing the PRT from refreshing" (research, check 7).
    $staleHours = Get-Parameter $Parameters 'PrtStaleHours' 4

    $check = Get-Text 'Check.OutlookAuth.Device'
    $state = Get-DataProperty $Data 'DeviceState'
    $failure = Get-DataProperty $state 'Error'
    if ($failure) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.DsregUnreadable') -f $failure) -Hint (Get-Text 'Hint.OutlookAuth.DsregUnread')
    }
    if (-not (Get-DataCollection $state 'Values').Count) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.DsregUnread')
    }

    $yes = { param($Key) (Get-OutlookAuthDeviceValue $state $Key) -eq 'YES' }
    $entra = & $yes 'AzureAdJoined'

    # Microsoft's table on its dsregcmd page: AzureAdJoined and DomainJoined together are
    # hybrid; EnterpriseJoined is the on-prem device registration service.
    $value = Get-Text 'Value.OutlookAuth.Join.None'
    if ($entra -and (& $yes 'DomainJoined')) { $value = Get-Text 'Value.OutlookAuth.Join.Hybrid' }
    elseif ($entra)                          { $value = Get-Text 'Value.OutlookAuth.Join.Entra' }
    elseif (& $yes 'EnterpriseJoined')       { $value = Get-Text 'Value.OutlookAuth.Join.Enterprise' }
    elseif (& $yes 'DomainJoined')           { $value = Get-Text 'Value.OutlookAuth.Join.Domain' }
    # WorkplaceJoined is the user's registered work account, in dsregcmd's User State.
    if ($UserUnread)                    { $value += Get-Text 'Value.OutlookAuth.Join.UserUnread' }
    elseif (& $yes 'WorkplaceJoined')   { $value += Get-Text 'Value.OutlookAuth.Join.Registered' }

    $hint = Get-Text 'Hint.OutlookAuth.DeviceJoined'
    if (-not $entra) {
        $hint = Get-Text 'Hint.OutlookAuth.DeviceNotJoined'
        if ($Mailbox.Location -eq 'OnPremises') { $hint = Get-Text 'Hint.OutlookAuth.DeviceOnPremises' }
    }
    New-Finding -Category Apps -Check $check -Severity INFO -Value $value -Hint $hint

    # A device not joined to Entra ID has no Entra device to check and gets no PRT;
    # Microsoft: the SSO state "can be ignored" for registered devices.
    if (-not $entra) { return }

    $check  = Get-Text 'Check.OutlookAuth.DeviceAuth'
    $status = Get-OutlookAuthDeviceValue $state 'DeviceAuthStatus'
    # Microsoft's three: SUCCESS, "FAILED. Device is either disabled or deleted", and
    # "FAILED. ERROR" when the test could not run. Only the second is the device's fault.
    if ($status -eq 'SUCCESS') {
        New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.OutlookAuth.DeviceAuthOk')
    }
    elseif ($status -match '^FAILED' -and $status -notmatch '^FAILED\.\s*ERROR') {
        New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value ((Get-Text 'Value.OutlookAuth.DeviceAuthFailed') -f $status) -Hint (Get-Text 'Hint.OutlookAuth.DeviceDisabled')
    }
    else {
        # Absent before Windows 10 21H1, or a value Microsoft does not document.
        $shown = Get-Text 'Value.OutlookAuth.DeviceAuthAbsent'
        if ($status) { $shown = (Get-Text 'Value.OutlookAuth.DeviceAuthUnknown') -f $status }
        New-Finding -Category Apps -Check $check -Severity INFO -Value $shown -Hint (Get-Text 'Hint.OutlookAuth.DeviceAuthUnknown')
    }

    if ($UserUnread) { return }

    $check = Get-Text 'Check.OutlookAuth.Prt'
    if (-not (& $yes 'AzureAdPrt')) {
        # Research, check 7: no PRT on a joined device is WARN; the Hint's error code is
        # the failed attempt's, when dsregcmd printed one.
        $value = Get-Text 'Value.OutlookAuth.PrtMissing'
        $codes = @(foreach ($k in $script:OutlookAuthDsregPrtDiagnostics) { Get-OutlookAuthDeviceValue $state $k }) | Where-Object { $_ }
        if ($codes) { $value += (Get-Text 'Value.OutlookAuth.PrtCode') -f ($codes -join ' ') }
        return New-Finding -Category Apps -Check $check -Severity WARN -Value $value -Hint (Get-Text 'Hint.OutlookAuth.PrtStale')
    }

    $printed = Get-OutlookAuthDeviceValue $state 'AzureAdPrtUpdateTime'
    $updated = ConvertFrom-OutlookAuthDsregTime $printed
    # PowerShell 7's ConvertFrom-Json turns an ISO time into a DateTime of its own accord,
    # so data read back from a file may carry ReadAt either way.
    $readAt  = Get-DataProperty $state 'ReadAt'
    if ($readAt -is [datetime]) { $readAt = $readAt.ToUniversalTime() }
    else { $readAt = ConvertFrom-OutlookAuthDsregTime "$readAt" }
    if (-not $updated -or -not $readAt) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.PrtTimeUnknown') -f $printed) -Hint (Get-Text 'Hint.OutlookAuth.PrtTimeUnknown')
    }

    $age = ($readAt - $updated).TotalHours
    $hours = [math]::Floor([math]::Max(0, $age))
    if ($age -gt $staleHours) {
        return New-Finding -Category Apps -Check $check -Severity WARN `
            -Value ((Get-Text 'Value.OutlookAuth.PrtStale') -f $hours, $updated.ToString('yyyy-MM-dd HH:mm', [Globalization.CultureInfo]::InvariantCulture)) `
            -Hint (Get-Text 'Hint.OutlookAuth.PrtStale')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.PrtFresh') -f $hours)
}

function Get-OutlookAuthUnknownSuffix {
    <#
    .SYNOPSIS
        What a Microsoft 365 Value adds when nobody knows the mailbox is in Microsoft 365.
    #>

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

    if ($Mailbox.Location -eq 'Unknown') { return (Get-Text 'Value.OutlookAuth.LocationUnknown') }
    ''
}

function New-OutlookAuthModernAuthFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.ModernAuth'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox
    $adal   = Get-OutlookAuthSetting -Data $Data -Group 'Identity' -Name 'EnableADAL'

    # EnableADAL=0 is basic authentication, which Exchange Online no longer accepts from
    # anyone (research, check 4).
    if (Test-OutlookAuthSettingIs $adal 0) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value ((Format-OutlookAuthSetting $adal) + $suffix) -Hint (Get-Text 'Hint.OutlookAuth.ModernAuthOff')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($adal) -Suffix $suffix
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.On') + $suffix)
}

function New-OutlookAuthWamFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.Wam'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox

    # Any of the three at 1 takes Office off WAM, which Microsoft calls unsupported - so one
    # read at 1 fails it whatever the others are, and only then does an unread one matter.
    $settings = @(foreach ($name in 'DisableADALatopWAMOverride', 'DisableAADWAM', 'DisableMSAWAM') {
        Get-OutlookAuthSetting -Data $Data -Group 'Identity' -Name $name
    })
    $off = @(foreach ($setting in $settings) {
        if (Test-OutlookAuthSettingIs $setting 1) { Format-OutlookAuthSetting $setting }
    })
    if ($off.Count) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value (($off -join ', ') + $suffix) -Hint (Get-Text 'Hint.OutlookAuth.WamOff')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting $settings -Suffix $suffix
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.On') + $suffix)
}

function New-OutlookAuthBrokerFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.Broker'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox

    $failure  = Get-DataProperty $Data 'PackagesError'
    $packages = Get-DataCollection $Data 'Packages'
    if ($failure -or -not $packages.Count) {
        $value = Get-Text 'Value.Shared.NotAvailable'
        if ($failure) { $value = (Get-Text 'Value.OutlookAuth.PackagesUnreadable') -f $failure }
        return New-Finding -Category Apps -Check $check -Severity INFO -Value $value `
            -Hint (Get-Text 'Hint.OutlookAuth.BrokerUnknown')
    }

    # A package with any status but Ok (Modified, Tampered, NeedsRemediation, ...) is one
    # Windows itself says is not whole.
    $wrong = @(foreach ($p in $packages) {
        $name = "$(Get-DataProperty $p 'Name')"
        if (-not (Get-DataProperty $p 'Present')) { (Get-Text 'Value.OutlookAuth.PackageMissing') -f $name; continue }
        $status = "$(Get-DataProperty $p 'Status')"
        if ($status -ne 'Ok') { (Get-Text 'Value.OutlookAuth.PackageStatus') -f $name, $status }
    })

    # A repair Windows had to start on the broker's package is the "broken package" of the
    # research, check 4: FAIL on its own.
    $events = Get-DataCollection $Data 'BrokerEvents'
    if ($events.Count) {
        $top = $events | Sort-Object { - (ConvertTo-Number (Get-DataProperty $_ 'Count')) } | Select-Object -First 1
        $wrong += (Get-Text 'Value.OutlookAuth.BrokerEvents') -f ($events | Measure-Object Count -Sum).Sum,
            (Get-DataProperty $Data 'Days'), (Get-DataProperty $top 'Code')
    }

    if ($wrong.Count) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value (($wrong -join '; ') + $suffix) -Hint (Get-Text 'Hint.OutlookAuth.BrokerBroken')
    }

    $present = @($packages | ForEach-Object { '{0} {1}' -f $_.Name, $_.Version }) -join ', '
    if ((Get-DataCollection $Data 'UnreadableLogs') -contains 'Application') {
        # The packages are there, but whether Windows has been repairing them is unread -
        # which is not the same as it not having done so.
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value (((Get-Text 'Value.OutlookAuth.BrokerLogUnread') -f $present) + $suffix) `
            -Hint (Get-Text 'Hint.OutlookAuth.ApplicationLogUnread')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value (((Get-Text 'Value.OutlookAuth.BrokerOk') -f $present) + $suffix)
}

function New-OutlookAuthAadFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox, [hashtable]$Parameters)

    # Unsourced: the research grades any 1081/1084/1088 as WARN and calls the number a
    # guess to tune once Runs have shown what an ordinary week looks like.
    $warnAbove = Get-Parameter $Parameters 'AadErrorWarnAbove' 0

    $check  = Get-Text 'Check.OutlookAuth.AadErrors'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox
    $days   = Get-DataProperty $Data 'Days'

    if ((Get-DataCollection $Data 'UnreadableLogs') -contains $script:OutlookAuthAadLog) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value (Get-Text 'Value.OutlookAuth.AadUnread') -Hint (Get-Text 'Hint.OutlookAuth.AadUnread')
    }

    $events  = Get-DataCollection $Data 'AadEvents'
    $graded  = @($events | Where-Object { $script:OutlookAuthAadGradedIds -contains [int](Get-DataProperty $_ 'Id') })
    $routine = @($events | Where-Object { $script:OutlookAuthAadGradedIds -notcontains [int](Get-DataProperty $_ 'Id') })

    $describe = {
        param($Rows)
        @($Rows | Sort-Object { - (ConvertTo-Number (Get-DataProperty $_ 'Count')) } | Select-Object -First 3 |
            ForEach-Object { (Get-Text 'Value.OutlookAuth.Code') -f $_.Code, $_.Id, $_.Count }) -join ', '
    }

    $count = 0
    if ($graded.Count) { $count = ($graded | Measure-Object Count -Sum).Sum }
    $value = (Get-Text 'Value.OutlookAuth.AadNone') -f $days
    if ($count) { $value = (Get-Text 'Value.OutlookAuth.AadErrors') -f $count, $days, (& $describe $graded) }
    if ($routine.Count) {
        $value += (Get-Text 'Value.OutlookAuth.AadRoutine') -f ($routine | Measure-Object Count -Sum).Sum, (& $describe $routine)
    }

    New-Finding -Category Apps -Check $check -Severity (Get-Severity $count $warnAbove ([double]::MaxValue)) `
        -Value ($value + $suffix) -Hint (Get-Text 'Hint.OutlookAuth.AadErrors')
}

function New-OutlookAuthMapiFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check   = Get-Text 'Check.OutlookAuth.MapiHttp'
    $setting = Get-OutlookAuthSetting -Data $Data -Group 'Exchange' -Name 'MapiHttpDisabled'

    if (Test-OutlookAuthSettingIs $setting 1) {
        # FAIL for Exchange Online, which has not spoken RPC over HTTP since 2017; WARN on
        # premises, where it still works but is a testing switch (research, check 4).
        # Unknown is graded as Exchange Online, like the rest of this Check.
        $severity = 'FAIL'
        if ($Mailbox.Location -eq 'OnPremises') { $severity = 'WARN' }
        return New-Finding -Category Apps -Check $check -Severity $severity `
            -Value ((Format-OutlookAuthSetting $setting) + (Get-OutlookAuthUnknownSuffix $Mailbox)) `
            -Hint (Get-Text 'Hint.OutlookAuth.MapiOff')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($setting) -Suffix (Get-OutlookAuthUnknownSuffix $Mailbox)
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.OutlookAuth.On')
}

function New-OutlookAuthTlsFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data)

    $check = Get-Text 'Check.OutlookAuth.Tls12'
    if (-not (Get-DataCollection $Data 'Settings').Count) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.NotRead')
    }

    $enabled  = Get-OutlookAuthSetting -Data $Data -Group 'Schannel' -Name 'Enabled'
    $disabled = Get-OutlookAuthSetting -Data $Data -Group 'Schannel' -Name 'DisabledByDefault'

    if (Test-OutlookAuthSettingIs $enabled 0) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value (Format-OutlookAuthSetting $enabled) -Hint (Get-Text 'Hint.OutlookAuth.Tls12Off')
    }
    # Inference, not graded by the research: with DisabledByDefault=1 a program gets TLS
    # 1.2 only when it asks for it by name, so whatever does not is left on older versions
    # Microsoft 365 refuses. Worth a WARN, not a FAIL.
    if (Test-OutlookAuthSettingIs $disabled 1) {
        return New-Finding -Category Apps -Check $check -Severity WARN `
            -Value (Format-OutlookAuthSetting $disabled) -Hint (Get-Text 'Hint.OutlookAuth.Tls12NotDefault')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($enabled, $disabled)
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.OutlookAuth.Tls12Default')
}

function New-OutlookAuthNtlmFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox, [hashtable]$Parameters)

    # Microsoft's figure: with Extended Protection on, LmCompatibilityLevel "must be set at
    # least to a value of 3" (Exchange Extended Protection, research check 9).
    $minimum = Get-Parameter $Parameters 'LmCompatibilityMinimum' 3

    $check   = Get-Text 'Check.OutlookAuth.Ntlm'
    $suffix  = Get-OutlookAuthUnknownSuffix $Mailbox
    $setting = Get-OutlookAuthSetting -Data $Data -Group 'Lsa' -Name 'LmCompatibilityLevel'

    # No row at all is a value never looked for; a row without a value is Windows' default.
    if (-not $setting) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.NotRead')
    }
    # A row whose key could not be read has no Value either, and is not Windows' default.
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($setting) -Suffix $suffix
    if ($unread) { return $unread }
    if ($null -eq (Get-DataProperty $setting 'Value')) {
        $level = $script:OutlookAuthLmDefault
        $shown = (Get-Text 'Value.OutlookAuth.LmAbsent') -f $script:OutlookAuthLmDefault
    }
    else {
        $level = ConvertTo-Number (Get-DataProperty $setting 'Value')
        $shown = Format-OutlookAuthSetting $setting
    }
    if ($null -eq $level) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Value ($shown + $suffix) `
            -Hint (Get-Text 'Hint.OutlookAuth.LmUnknown')
    }

    if ($level -lt $minimum) {
        # FAIL where the research grades it, on premises. Unknown is WARN: the setting only
        # hurts against an Exchange server, and nobody knows there is one (Inference).
        $severity = 'WARN'
        if ($Mailbox.Location -eq 'OnPremises') { $severity = 'FAIL' }
        return New-Finding -Category Apps -Check $check -Severity $severity `
            -Value (((Get-Text 'Value.OutlookAuth.LmTooLow') -f $shown, $minimum) + $suffix) `
            -Hint (Get-Text 'Hint.OutlookAuth.LmTooLow')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value ($shown + $suffix)
}

function New-OutlookAuthSavedCredentialFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.SavedCredentials'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox

    $failure = Get-DataProperty $Data 'SavedCredentialsError'
    if ($failure) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.CredentialsUnreadable') -f $failure) `
            -Hint (Get-Text 'Hint.OutlookAuth.CredentialsUnread')
    }
    if ($null -eq (Get-DataProperty $Data 'SavedCredentialTotal')) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.CredentialsUnread')
    }

    # Presence is INFO, not WARN: the entries hurt only when Outlook keeps prompting, which
    # a Run cannot see (research, check 9, Inference). The Hint carries the condition.
    $names = Get-DataCollection $Data 'SavedCredentials'
    if ($names.Count) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value (((Get-Text 'Value.OutlookAuth.SspiFound') -f $names.Count, ($names -join ', ')) + $suffix) `
            -Hint (Get-Text 'Hint.OutlookAuth.SspiStale')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.SspiNone') + $suffix)
}

function ConvertTo-OutlookAuthSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $settings = Get-DataCollection $Data 'Settings'
    $packages = Get-DataCollection $Data 'Packages'
    $state    = Get-DataProperty $Data 'DeviceState'
    $device   = Get-DataCollection $state 'Values'
    if ($settings.Count -or $packages.Count -or $device.Count -or (Get-DataProperty $state 'Error')) {
        New-Section -Title (Get-Text 'Title.OutlookAuth.Settings') -Row @(
            foreach ($s in $settings) {
                $failure = Get-DataProperty $s 'Error'
                if ($failure) {
                    # Where it failed and why, as the other unread rows below say it.
                    [pscustomobject]@{ Item = '{0}\{1}' -f $s.Group, $s.Name; Value = ('{0}: {1}' -f (Get-DataProperty $s 'Path'), $failure); Source = 'Error' }
                    continue
                }
                [pscustomobject]@{ Item = '{0}\{1}' -f $s.Group, $s.Name; Value = $s.Value; Source = $s.Source }
            }
            foreach ($p in $packages) {
                $state = 'missing'
                if ($p.Present) { $state = '{0} {1}' -f $p.Version, $p.Status }
                [pscustomobject]@{ Item = 'AppX\' + $p.Name; Value = $state; Source = 'CurrentUser' }
            }
            if (Get-DataProperty $Data 'PackagesError') {
                [pscustomobject]@{ Item = 'AppX'; Value = Get-DataProperty $Data 'PackagesError'; Source = 'Error' }
            }
            foreach ($name in (Get-DataCollection $Data 'SavedCredentials')) {
                [pscustomobject]@{ Item = 'Credential Manager'; Value = $name; Source = 'CurrentUser' }
            }
            if (Get-DataProperty $Data 'SavedCredentialsError') {
                [pscustomobject]@{ Item = 'Credential Manager'; Value = Get-DataProperty $Data 'SavedCredentialsError'; Source = 'Error' }
            }
            elseif ($null -ne (Get-DataProperty $Data 'SavedCredentialTotal')) {
                [pscustomobject]@{ Item = 'Credential Manager entries'; Value = Get-DataProperty $Data 'SavedCredentialTotal'; Source = 'CurrentUser' }
            }
            # dsregcmd's own words, with the section it printed them in as the Source: the
            # user's half (User State, SSO State) is told apart from the device's.
            foreach ($d in $device) {
                [pscustomobject]@{ Item = 'dsregcmd\' + $d.Key; Value = $d.Value; Source = $d.Section }
            }
            if (Get-DataProperty $state 'Error') {
                [pscustomobject]@{ Item = 'dsregcmd'; Value = Get-DataProperty $state 'Error'; Source = 'Error' }
            }
            # The folders the research says security software must not scan, so a
            # Technician following the broker Hint has them at hand.
            foreach ($folder in '%LOCALAPPDATA%\Packages\Microsoft.AAD.BrokerPlugin_cw5n1h2txyewy', '%LOCALAPPDATA%\Microsoft\TokenBroker',
                                '%LOCALAPPDATA%\Microsoft\OneAuth', '%LOCALAPPDATA%\Microsoft\IdentityCache') {
                [pscustomobject]@{ Item = 'Exclude from scanning'; Value = $folder; Source = $null }
            }
        )
    }

    $rows = @(
        foreach ($e in (Get-DataCollection $Data 'AadEvents')) {
            [pscustomobject]@{ Log = $script:OutlookAuthAadLog; Id = $e.Id; Code = $e.Code; Count = $e.Count; Last = $e.Last }
        }
        foreach ($e in (Get-DataCollection $Data 'BrokerEvents')) {
            [pscustomobject]@{ Log = 'Application (' + $script:OutlookAuthAppModelProvider + ')'; Id = $e.Id; Code = $e.Code; Count = $e.Count; Last = $e.Last }
        }
        foreach ($log in (Get-DataCollection $Data 'UnreadableLogs')) {
            [pscustomobject]@{ Log = $log; Id = $null; Code = 'not readable'; Count = $null; Last = $null }
        }
    )
    if ($rows.Count) { New-Section -Title (Get-Text 'Title.OutlookAuth.Events') -Row $rows }
}