internal/functions/Invoke-XdrSsoAuthentication.ps1

function Get-XdrSsoDefaultProfilePath {
    [CmdletBinding()]
    param()

    if ($IsWindows) {
        return Join-Path $env:LOCALAPPDATA 'XdrInternals\SsoEdgeProfile'
    }

    if ($IsMacOS) {
        return Join-Path $HOME 'Library/Application Support/XdrInternals/SsoBrowserProfile'
    }

    if ($IsLinux) {
        return Join-Path $HOME '.config/XdrInternals/sso-browser-profile'
    }

    throw 'Connect-XdrBySSO is not supported on this operating system.'
}

function Start-XdrSsoBrowserProcess {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private helper that launches the dedicated SSO browser process.')]
    [OutputType([System.Diagnostics.Process])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$BrowserPath,

        [Parameter(Mandatory)]
        [string[]]$ArgumentList,

        [switch]$Visible
    )

    $formattedArgumentList = Format-XdrProcessArgumentList -ArgumentList $ArgumentList

    if ($Visible -or -not $IsWindows) {
        return Start-Process -FilePath $BrowserPath -ArgumentList $formattedArgumentList -PassThru
    }

    return Start-Process -FilePath $BrowserPath -ArgumentList $formattedArgumentList -PassThru -WindowStyle Hidden -RedirectStandardError 'NUL'
}

function Initialize-XdrSsoProfile {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private helper that prepares the dedicated SSO browser profile.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ProfilePath
    )

    if (-not (Test-Path -LiteralPath $ProfilePath)) {
        $null = New-Item -ItemType Directory -Path $ProfilePath -Force
    }

    if (-not $IsWindows) {
        return
    }

    $defaultProfilePath = Join-Path $ProfilePath 'Default'
    if (-not (Test-Path -LiteralPath $defaultProfilePath)) {
        $null = New-Item -ItemType Directory -Path $defaultProfilePath -Force
    }

    $preferencesPath = Join-Path $defaultProfilePath 'Preferences'
    if (Test-Path -LiteralPath $preferencesPath) {
        return
    }

    @{
        sync    = @{ requested = $false }
        signin  = @{ allowed = $true }
        browser = @{ has_seen_welcome_page = $true }
    } | ConvertTo-Json -Depth 5 | Set-Content -Path $preferencesPath -Encoding UTF8
}

function Get-XdrSsoLaunchArgumentList {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ProfilePath,

        [Parameter(Mandatory)]
        [int]$DebugPort,

        [Parameter(Mandatory)]
        [string]$StartUrl,

        [switch]$Visible,

        [string]$UserAgent
    )

    $arguments = @(
        "--remote-debugging-port=$DebugPort",
        '--remote-allow-origins=*',
        "--user-data-dir=$ProfilePath",
        '--no-first-run',
        '--no-default-browser-check',
        '--disable-default-apps',
        '--disable-features=msEdgeSyncConsent,EdgeSync,msEdgeWelcomePage,msEdgeSidebarV2'
    )

    if (-not $Visible) {
        $arguments = @(
            '--headless=new',
            '--log-level=3',
            '--disable-gpu',
            '--disable-extensions',
            '--disable-sync',
            '--disable-background-networking',
            '--disable-component-update'
        ) + $arguments
    }

    if ($UserAgent) {
        $arguments = @("--user-agent=$UserAgent") + $arguments
    }

    return $arguments + @($StartUrl)
}

function New-XdrSsoCookieWebSession {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private helper that creates an in-memory web session wrapper around captured cookies.')]
    [OutputType([Microsoft.PowerShell.Commands.WebRequestSession])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$SccAuthCookieValue,

        [string]$XsrfToken,

        [string]$UserAgent
    )

    $session = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
    if ($UserAgent) {
        $session.UserAgent = $UserAgent
    }

    $session.Cookies.Add((New-Object System.Net.Cookie('sccauth', $SccAuthCookieValue, '/', 'security.microsoft.com')))
    if ($XsrfToken) {
        $session.Cookies.Add((New-Object System.Net.Cookie('XSRF-TOKEN', $XsrfToken, '/', 'security.microsoft.com')))
    }

    return $session
}

function Get-XdrSsoXsrfToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$SccAuthCookieValue,

        [string]$TenantId,

        [string]$UserAgent
    )

    $session = New-XdrSsoCookieWebSession -SccAuthCookieValue $SccAuthCookieValue -UserAgent $UserAgent
    $securityPortalUri = if ($TenantId) {
        "https://security.microsoft.com/?tid=$TenantId"
    } else {
        'https://security.microsoft.com/'
    }

    $null = Invoke-WebRequest -UseBasicParsing -ErrorAction SilentlyContinue -WebSession $session -Method Get -Uri $securityPortalUri -Verbose:$false
    return $session.Cookies.GetCookies('https://security.microsoft.com')['xsrf-token'].Value
}

function Get-XdrSsoTenantList {
    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$SccAuthCookieValue,

        [string]$XsrfToken,

        [string]$TenantId,

        [string]$UserAgent
    )

    $resolvedXsrfToken = $XsrfToken
    if (-not $resolvedXsrfToken) {
        $resolvedXsrfToken = Get-XdrSsoXsrfToken -SccAuthCookieValue $SccAuthCookieValue -TenantId $TenantId -UserAgent $UserAgent
    }

    $session = New-XdrSsoCookieWebSession -SccAuthCookieValue $SccAuthCookieValue -XsrfToken $resolvedXsrfToken -UserAgent $UserAgent
    $headers = @{ mtoproxyurl = 'MTO' }
    if ($resolvedXsrfToken) {
        $headers['X-XSRF-TOKEN'] = [System.Net.WebUtility]::UrlDecode($resolvedXsrfToken)
    }

    $tenantPicker = Invoke-RestMethod -Uri 'https://security.microsoft.com/apiproxy/mtoapi/tenants/TenantPicker' -ContentType 'application/json' -WebSession $session -Headers $headers -ErrorAction Stop
    return @($tenantPicker.tenantInfoList)
}

function Resolve-XdrSsoTenantSelection {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    [CmdletBinding()]
    param(
        [object[]]$Tenants,

        [string]$RequestedTenant,

        [switch]$SkipTenantSelection
    )

    if ($RequestedTenant) {
        $match = @(
            $Tenants | Where-Object { $_.tenantId -eq $RequestedTenant }
        ) | Where-Object { $_ } | Select-Object -First 1

        if ($match) {
            return [pscustomobject]@{
                TenantId   = $match.tenantId
                TenantName = $match.name
            }
        }

        return [pscustomobject]@{
            TenantId   = $RequestedTenant
            TenantName = $null
        }
    }

    if (-not $Tenants -or $Tenants.Count -eq 0) {
        return $null
    }

    if ($SkipTenantSelection -or $Tenants.Count -eq 1) {
        $selectedTenant = @($Tenants | Where-Object { $_.selected -eq $true } | Select-Object -First 1)
        if (-not $selectedTenant) {
            $selectedTenant = @($Tenants | Select-Object -First 1)
        }

        return [pscustomobject]@{
            TenantId   = $selectedTenant[0].tenantId
            TenantName = $selectedTenant[0].name
        }
    }

    Write-Host 'Available tenants:'
    for ($i = 0; $i -lt $Tenants.Count; $i++) {
        Write-Host " [$($i + 1)] $($Tenants[$i].name) ($($Tenants[$i].tenantId))"
    }

    while ($true) {
        $selection = Read-Host "Select tenant [1-$($Tenants.Count)]"
        $index = 0
        if ([int]::TryParse($selection, [ref]$index) -and $index -ge 1 -and $index -le $Tenants.Count) {
            return [pscustomobject]@{
                TenantId   = $Tenants[$index - 1].tenantId
                TenantName = $Tenants[$index - 1].name
            }
        }

        Write-Host 'Invalid selection. Try again.'
    }
}

function Invoke-XdrSsoAuthentication {
    <#
    .SYNOPSIS
        Performs browser-based SSO authentication and returns Defender portal authentication artifacts.

    .DESCRIPTION
        Starts a dedicated browser profile, lets the operating system and browser perform silent
        sign-in when possible, and extracts Defender portal cookies through the browser DevTools
        protocol. This is intended for Windows-first SSO scenarios, but can also reuse existing
        browser session state on macOS and Linux when a supported Chromium-based browser is available.

    .PARAMETER TenantId
        Optional tenant ID (GUID) used to select the final tenant after sign-in.

    .PARAMETER Visible
        Shows the browser window instead of using a headless launch.

    .PARAMETER SkipTenantSelection
        Automatically uses the selected tenant or the first tenant when multiple tenants are available.

    .PARAMETER TimeoutSeconds
        Maximum time to wait for browser sign-in and cookie capture.

    .PARAMETER BrowserPath
        Optional browser executable path or command name.

    .PARAMETER ProfilePath
        Optional persistent browser profile path used for SSO.

    .PARAMETER UserAgent
        Optional User-Agent override for the launched browser.

    .OUTPUTS
        PSCustomObject containing browser authentication artifacts and resolved tenant information.

    .EXAMPLE
        Invoke-XdrSsoAuthentication

        Attempts silent browser SSO using the default dedicated profile and returns the captured
        Defender portal authentication artifacts.

    .EXAMPLE
        Invoke-XdrSsoAuthentication -Visible -SkipTenantSelection

        Shows the browser window while the SSO flow completes and automatically selects the active tenant.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    [CmdletBinding()]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')]
        [string]$TenantId,

        [switch]$Visible,

        [switch]$SkipTenantSelection,

        [ValidateRange(30, 1800)]
        [int]$TimeoutSeconds = 180,

        [string]$BrowserPath,

        [string]$ProfilePath,

        [string]$UserAgent
    )

    if (-not ($IsWindows -or $IsMacOS -or $IsLinux)) {
        throw 'Connect-XdrBySSO is not supported on this operating system.'
    }

    $browser = Resolve-XdrBrowserPath -BrowserPath $BrowserPath
    $resolvedProfilePath = if ($ProfilePath) { $ProfilePath } else { Get-XdrSsoDefaultProfilePath }
    Initialize-XdrSsoProfile -ProfilePath $resolvedProfilePath

    $debugPort = Get-XdrBrowserFreeTcpPort
    $startUrl = 'https://security.microsoft.com/'
    $arguments = Get-XdrSsoLaunchArgumentList -ProfilePath $resolvedProfilePath -DebugPort $debugPort -StartUrl $startUrl -Visible:$Visible -UserAgent $UserAgent
    $browserProcess = $null

    try {
        Write-Host "Launching $($browser.Name) for SSO sign-in..."
        if ($Visible) {
            Write-Host 'A browser window will open. Silent sign-in should occur automatically if the browser profile and device state allow it.'
        } else {
            Write-Host 'Attempting silent browser SSO in headless mode...'
        }

        $browserProcess = Start-XdrSsoBrowserProcess -BrowserPath $browser.Path -ArgumentList $arguments -Visible:$Visible

        $versionInfo = Get-XdrBrowserCdpVersion -Port $debugPort -TimeoutSeconds 20
        $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
        $estsAuthCookieValue = $null
        $sccAuthCookieValue = $null
        $xsrfToken = $null
        $firstEstsCookieObservedAt = $null
        $lastObservedTargetDescription = $null

        do {
            Start-Sleep -Seconds 2

            if ($browserProcess) {
                $browserProcess.Refresh()
                if ($browserProcess.HasExited) {
                    if ($Visible) {
                        $message = 'The browser window closed before SSO authentication completed.'
                        if ($lastObservedTargetDescription) {
                            $message += " Last observed browser page: $lastObservedTargetDescription"
                        }

                        throw $message
                    }

                    $message = 'The browser exited before SSO authentication completed. Retry with -Visible to observe the flow on this device.'
                    if ($lastObservedTargetDescription) {
                        $message += " Last observed browser page: $lastObservedTargetDescription"
                    }

                    throw $message
                }
            }

            try {
                $targetContext = Get-XdrBrowserPreferredTargetContext -Port $debugPort -FallbackWebSocketUrl $versionInfo.webSocketDebuggerUrl
                $currentTargetDescription = Format-XdrBrowserTargetDescription -Url $targetContext.Url -Title $targetContext.Title
                if ($currentTargetDescription -and $currentTargetDescription -ne $lastObservedTargetDescription) {
                    $lastObservedTargetDescription = $currentTargetDescription
                    Write-Verbose "Observed browser page: $currentTargetDescription"
                }

                $cookies = @(Get-XdrBrowserCookieJar -WebSocketUrl $targetContext.WebSocketUrl)
            } catch {
                Write-Verbose "Cookie polling failed: $($_.Exception.Message)"
                continue
            }

            $selectedEstsCookie = Get-XdrBestBrowserEstsCookie -Cookies $cookies
            $estsAuthCookieValue = if ($selectedEstsCookie) { $selectedEstsCookie.value } else { $null }

            $sccAuthCookieValue = Get-XdrBrowserCookieValue -Cookies $cookies -Name 'sccauth' -DomainLike 'security.microsoft.com'
            $xsrfToken = Get-XdrBrowserCookieValue -Cookies $cookies -Name 'XSRF-TOKEN' -DomainLike 'security.microsoft.com'

            if ($selectedEstsCookie -and -not $firstEstsCookieObservedAt) {
                $firstEstsCookieObservedAt = Get-Date
                Write-Verbose 'Captured ESTS authentication cookie. Waiting for Defender portal cookies to appear before falling back to ESTS bootstrap.'
            }

            if (Test-XdrBrowserAuthenticationCompletion -SccAuthCookieValue $sccAuthCookieValue -EstsCookie $selectedEstsCookie -FirstEstsCookieObservedAt $firstEstsCookieObservedAt -Deadline $deadline) {
                break
            }
        } while ((Get-Date) -lt $deadline)

        if (-not $sccAuthCookieValue -and -not $estsAuthCookieValue) {
            $message = 'SSO authentication did not produce Defender portal or ESTS cookies before the timeout expired.'
            if ($lastObservedTargetDescription) {
                $message += " Last observed browser page: $lastObservedTargetDescription"
            }

            throw $message
        }

        $selectedTenant = $null
        if ($sccAuthCookieValue) {
            try {
                if (-not $xsrfToken) {
                    $xsrfToken = Get-XdrSsoXsrfToken -SccAuthCookieValue $sccAuthCookieValue -TenantId $TenantId -UserAgent $UserAgent
                }

                $tenants = Get-XdrSsoTenantList -SccAuthCookieValue $sccAuthCookieValue -XsrfToken $xsrfToken -TenantId $TenantId -UserAgent $UserAgent
                $selectedTenant = Resolve-XdrSsoTenantSelection -Tenants $tenants -RequestedTenant $TenantId -SkipTenantSelection:$SkipTenantSelection
            } catch {
                Write-Verbose "Tenant selection skipped: $($_.Exception.Message)"
            }
        }

        return [pscustomobject]@{
            EstsAuthCookieValue = $estsAuthCookieValue
            SccAuthCookieValue  = $sccAuthCookieValue
            XsrfToken           = $xsrfToken
            SelectedTenantId    = if ($selectedTenant) { $selectedTenant.TenantId } else { $TenantId }
            SelectedTenantName  = if ($selectedTenant) { $selectedTenant.TenantName } else { $null }
            ProfilePath         = $resolvedProfilePath
        }
    } finally {
        if ($browserProcess) {
            $browserProcess.Refresh()
            if (-not $browserProcess.HasExited) {
                Stop-Process -Id $browserProcess.Id -Force -ErrorAction SilentlyContinue
            }
        }
    }
}