internal/functions/Invoke-XdrCredentialAuthentication.ps1

#region Private Helper Functions

function Get-XdrTotpCode {
    <#
    .SYNOPSIS
        Computes a TOTP code from a base32-encoded secret.
    #>

    param(
        [Parameter(Mandatory)]
        [string]$Secret,
        [int]$Digits = 6,
        [int]$Period = 30
    )

    # Decode base32
    $base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
    $cleanSecret = $Secret.ToUpper().TrimEnd('=') -replace '\s', ''
    $bits = ""
    foreach ($c in $cleanSecret.ToCharArray()) {
        $idx = $base32Chars.IndexOf($c)
        if ($idx -lt 0) { throw "Invalid base32 character: $c" }
        $bits += [Convert]::ToString($idx, 2).PadLeft(5, '0')
    }
    $keyBytes = [byte[]]::new([Math]::Floor($bits.Length / 8))
    for ($i = 0; $i -lt $keyBytes.Length; $i++) {
        $keyBytes[$i] = [Convert]::ToByte($bits.Substring($i * 8, 8), 2)
    }

    # Time counter
    $epoch = [long][Math]::Floor(([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) / $Period)
    $counterBytes = [BitConverter]::GetBytes($epoch)
    if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($counterBytes) }

    # HMAC-SHA1
    $hmac = New-Object System.Security.Cryptography.HMACSHA1(, $keyBytes)
    try {
        $hash = $hmac.ComputeHash($counterBytes)
    } finally {
        $hmac.Dispose()
    }

    # Dynamic truncation
    $offset = $hash[$hash.Length - 1] -band 0x0F
    $code = (($hash[$offset] -band 0x7F) -shl 24) -bor
    ($hash[$offset + 1] -shl 16) -bor
    ($hash[$offset + 2] -shl 8) -bor
    $hash[$offset + 3]

    return ($code % [Math]::Pow(10, $Digits)).ToString().PadLeft($Digits, '0')
}

function Test-XdrMfaAuthSucceeded {
    param($Response)

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

    if ($Response.ResultValue -eq 'AuthenticationSucceeded') {
        return $true
    }

    if ($Response.Success -eq $true -and $Response.ResultValue -eq 'Success') {
        return $true
    }

    return $false
}

function Get-XdrEstsApiHeaderSet {
    param($AuthState)

    $headers = @{}
    if (-not $AuthState) {
        return $headers
    }

    if ($AuthState.canary) {
        $headers['canary'] = [string]$AuthState.canary
    }

    if ($AuthState.correlationId) {
        $headers['client-request-id'] = [string]$AuthState.correlationId
    }

    if ($null -ne $AuthState.hpgid) {
        $headers['hpgid'] = [string]$AuthState.hpgid
    }

    if ($null -ne $AuthState.hpgact) {
        $headers['hpgact'] = [string]$AuthState.hpgact
    }

    $headers['Accept'] = 'application/json'
    $headers['X-Requested-With'] = 'XMLHttpRequest'

    return $headers
}

function Test-XdrProcessAuthRetryableError {
    param($ParsedState)

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

    if ($ParsedState.iErrorCode -notin @(90014, 9000410)) {
        return $false
    }

    $message = [string]$ParsedState.strServiceExceptionMessage
    return (
        $message -match "required field .*request.* missing" -or
        $message -match 'Malformed JSON'
    )
}

function Get-XdrProcessAuthRequestBody {
    param(
        [Parameter(Mandatory)]
        [string]$SelectedMethod,
        [Parameter(Mandatory)]
        [string]$Username,
        [Parameter(Mandatory)]
        [string]$ProcessRequest,
        [Parameter(Mandatory)]
        $BeginAuth,
        [Parameter(Mandatory)]
        $AuthState,
        [Nullable[long]]$MfaLastPollStart,
        [Nullable[long]]$MfaLastPollEnd
    )

    if ($SelectedMethod -eq 'PhoneAppNotification') {
        $body = [ordered]@{
            type               = 22
            request            = $ProcessRequest
            mfaAuthMethod      = $SelectedMethod
            login              = $Username
            flowToken          = $BeginAuth.FlowToken
            hpgrequestid       = $AuthState.correlationId
            sacxt              = ''
            hideSmsInMfaProofs = 'false'
            canary             = $AuthState.canary
        }

        if ($null -ne $MfaLastPollStart) {
            $body['mfaLastPollStart'] = [string]$MfaLastPollStart
        }

        if ($null -ne $MfaLastPollEnd) {
            $body['mfaLastPollEnd'] = [string]$MfaLastPollEnd
        }

        if ($null -ne $AuthState.i19) {
            $body['i19'] = [string]$AuthState.i19
        }

        return $body
    }

    return @{
        type      = 22
        FlowToken = $BeginAuth.FlowToken
        request   = $ProcessRequest
        ctx       = $BeginAuth.Ctx
    } | ConvertTo-Json
}

function Get-XdrAuthStateFromResponse {
    param($Response)

    if ($null -eq $Response -or [string]::IsNullOrWhiteSpace($Response.Content)) {
        return $null
    }

    if ($Response.Content -match '{(.*)}') {
        try {
            return $Matches[0] | ConvertFrom-Json
        } catch {
            return $null
        }
    }

    return $null
}

function Resolve-XdrAuthAbsoluteUri {
    param(
        [Parameter(Mandatory)]
        [string]$Uri,
        [string]$BaseUri = 'https://login.microsoftonline.com/'
    )

    if ([string]::IsNullOrWhiteSpace($Uri)) {
        return $null
    }

    return [uri]::new([uri]$BaseUri, $Uri).AbsoluteUri
}

function Get-XdrBestEstsCookieValue {
    param(
        [Parameter(Mandatory)]
        [Microsoft.PowerShell.Commands.WebRequestSession]$Session
    )

    $allCookies = @($Session.Cookies.GetCookies('https://login.microsoftonline.com'))
    $estsCookies = @($allCookies | Where-Object Name -Like 'ESTS*')
    if (-not $estsCookies) {
        return $null
    }

    $bestCookie = @(
        $allCookies | Where-Object Name -EQ 'ESTSAUTH'
        $allCookies | Where-Object Name -EQ 'ESTSAUTHPERSISTENT'
        $allCookies | Where-Object Name -EQ 'ESTSAUTHLIGHT'
        $estsCookies
    ) | Where-Object { $_ } | Sort-Object { $_.Value.Length } -Descending | Select-Object -First 1

    return $bestCookie.Value
}

function ConvertTo-XdrFormUrlEncodedBody {
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Data
    )

    return (@(
            foreach ($entry in $Data.GetEnumerator()) {
                "$([uri]::EscapeDataString([string]$entry.Key))=$([uri]::EscapeDataString([string]$entry.Value))"
            }
        ) -join '&')
}

function Get-XdrHtmlFormPost {
    param($Response)

    if ($null -eq $Response -or [string]::IsNullOrWhiteSpace($Response.Content)) {
        return $null
    }

    $actionMatch = [regex]::Match($Response.Content, 'action="([^"]+)"')
    if (-not $actionMatch.Success) {
        return $null
    }

    $action = $actionMatch.Groups[1].Value

    $fields = [ordered]@{}
    foreach ($match in [regex]::Matches($Response.Content, '<input[^>]+name="([^"]+)"[^>]+value="([^"]*)"')) {
        $fields[$match.Groups[1].Value] = $match.Groups[2].Value
    }

    if ($fields.Count -eq 0) {
        return $null
    }

    return [pscustomobject]@{
        Action = $action
        Fields = $fields
        Body   = ConvertTo-XdrFormUrlEncodedBody -Data $fields
    }
}

function Get-XdrResponseLocation {
    param($Response)

    if ($null -eq $Response) {
        return $null
    }

    if ($Response.Headers -and $Response.Headers.Location) {
        return [string]$Response.Headers.Location
    }

    if ($Response.BaseResponse -and $Response.BaseResponse.Headers -and $Response.BaseResponse.Headers['Location']) {
        return [string]$Response.BaseResponse.Headers['Location']
    }

    return $null
}

function Invoke-XdrRedirectCapturingWebRequest {
    param(
        [Parameter(Mandatory)]
        [string]$Uri,
        [Parameter(Mandatory)]
        [string]$Method,
        [Microsoft.PowerShell.Commands.WebRequestSession]$Session,
        $Body,
        $Headers,
        [string]$ContentType
    )

    $requestParams = @{
        Uri                = $Uri
        Method             = $Method
        UseBasicParsing    = $true
        SkipHttpErrorCheck = $true
        MaximumRedirection = 0
        Verbose            = $false
        ErrorAction        = 'SilentlyContinue'
    }

    if ($PSBoundParameters.ContainsKey('Session')) {
        $requestParams['WebSession'] = $Session
    }

    if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
        $requestParams['Body'] = $Body
    }

    if ($PSBoundParameters.ContainsKey('Headers') -and $null -ne $Headers) {
        $requestParams['Headers'] = $Headers
    }

    if ($PSBoundParameters.ContainsKey('ContentType') -and -not [string]::IsNullOrWhiteSpace($ContentType)) {
        $requestParams['ContentType'] = $ContentType
    }

    $redirectErrors = @()
    $response = Invoke-WebRequest @requestParams -ErrorVariable +redirectErrors
    if ($null -ne $response) {
        return $response
    }

    foreach ($errorRecord in $redirectErrors) {
        $redirectResponse = if ($errorRecord.Exception) { $errorRecord.Exception.Response } else { $null }
        if ($null -ne $redirectResponse -and (Get-XdrResponseLocation -Response $redirectResponse)) {
            return $redirectResponse
        }

        if ($errorRecord.Exception -and $errorRecord.Exception.Message -match 'maximum redirection count has been exceeded') {
            Write-Verbose "Captured redirect response from $Method $Uri after PowerShell reported the redirection limit."
            continue
        }

        throw $errorRecord
    }

    throw "Web request to '$Uri' did not return a usable response."
}

function Test-XdrSecurityPortalFormPostResponse {
    param($Response)

    if ($null -eq $Response -or $null -eq $Response.InputFields) {
        return $false
    }

    $requiredFields = @('code', 'id_token', 'state', 'session_state', 'correlation_id')
    $inputNames = @($Response.InputFields | Where-Object { $_.name } | Select-Object -ExpandProperty name)

    foreach ($field in $requiredFields) {
        if ($inputNames -notcontains $field) {
            return $false
        }
    }

    return $true
}

function Complete-XdrSecurityPortalFormPost {
    param(
        $Response,
        [Parameter(Mandatory)]
        [Microsoft.PowerShell.Commands.WebRequestSession]$Session
    )

    $requiredFields = @('code', 'id_token', 'state', 'session_state', 'correlation_id')
    $body = @{}
    foreach ($field in $requiredFields) {
        $body[$field] = $Response.InputFields | Where-Object name -EQ $field | Select-Object -ExpandProperty value
    }

    $postUri = if ($Response.BaseResponse -and $Response.BaseResponse.ResponseUri) {
        $Response.BaseResponse.ResponseUri.GetLeftPart([System.UriPartial]::Path)
    } else {
        'https://security.microsoft.com/'
    }

    Write-Verbose "Completing security.microsoft.com form POST at $postUri"
    return Invoke-WebRequest -UseBasicParsing -Method Post -Uri $postUri -Body $body -WebSession $Session -MaximumRedirection 10 -SkipHttpErrorCheck -Verbose:$false
}

function Resolve-XdrAuthenticationResponse {
    param(
        $Response,
        [Parameter(Mandatory)]
        [Microsoft.PowerShell.Commands.WebRequestSession]$Session
    )

    $currentResponse = $Response

    for ($redirectCount = 0; $redirectCount -lt 5 -and $null -ne $currentResponse; $redirectCount++) {
        $authState = Get-XdrAuthStateFromResponse -Response $currentResponse
        if ($authState) {
            return [pscustomobject]@{
                AuthState = $authState
                Response  = $currentResponse
            }
        }

        if (Test-XdrSecurityPortalFormPostResponse -Response $currentResponse) {
            $currentResponse = Complete-XdrSecurityPortalFormPost -Response $currentResponse -Session $Session
            continue
        }

        $location = Get-XdrResponseLocation -Response $currentResponse
        if (-not $location) {
            break
        }

        $baseUri = if ($currentResponse.BaseResponse -and $currentResponse.BaseResponse.ResponseUri) {
            $currentResponse.BaseResponse.ResponseUri
        } else {
            [uri]'https://login.microsoftonline.com/'
        }

        $nextUri = [uri]::new($baseUri, $location)
        if ($nextUri.Scheme -notin @('http', 'https')) {
            Write-Verbose "Authentication redirect reached native callback URI $nextUri; stopping redirect resolution."
            break
        }

        Write-Verbose "Following authentication redirect to $nextUri"
        $currentResponse = Invoke-WebRequest -UseBasicParsing -Method Get -Uri $nextUri -WebSession $Session -MaximumRedirection 10 -SkipHttpErrorCheck -Verbose:$false
    }

    return [pscustomobject]@{
        AuthState = (Get-XdrAuthStateFromResponse -Response $currentResponse)
        Response  = $currentResponse
    }
}

function Get-XdrSupportedMfaOption {
    param($AuthState)

    $descriptions = @{
        PhoneAppOTP          = 'Authenticator app code'
        PhoneAppNotification = 'Authenticator app approval'
        OneWaySMS            = 'Text message code'
    }

    $supportedMethods = [ordered]@{}
    foreach ($proof in @($AuthState.arrUserProofs)) {
        if (-not $proof.authMethodId -or -not $descriptions.ContainsKey($proof.authMethodId)) {
            continue
        }

        if ($supportedMethods.Contains($proof.authMethodId)) {
            continue
        }

        $supportedMethods[$proof.authMethodId] = [pscustomobject]@{
            AuthMethodId = $proof.authMethodId
            Description  = $descriptions[$proof.authMethodId]
            IsDefault    = [bool]$proof.isDefault
        }
    }

    return @(
        $supportedMethods.Values | Sort-Object -Property @(
            @{ Expression = { if ($_.IsDefault) { 0 } else { 1 } } },
            @{ Expression = { $_.AuthMethodId } }
        )
    )
}

function Select-XdrMfaMethod {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    param(
        [Parameter(Mandatory)]
        [object[]]$SupportedMethods,
        [string]$PreferredMethod,
        [string]$TotpSecret
    )

    $supportedMethodIds = @($SupportedMethods | ForEach-Object AuthMethodId)

    if ($PreferredMethod) {
        if ($supportedMethodIds -contains $PreferredMethod) {
            return $PreferredMethod
        }

        throw "Requested MFA method '$PreferredMethod' is not offered for this sign-in. Supported methods: $($supportedMethodIds -join ', ')."
    }

    if ($TotpSecret -and $supportedMethodIds -contains 'PhoneAppOTP') {
        return 'PhoneAppOTP'
    }

    if ($SupportedMethods.Count -eq 1) {
        return $SupportedMethods[0].AuthMethodId
    }

    Write-Host 'Available MFA methods:'
    for ($i = 0; $i -lt $SupportedMethods.Count; $i++) {
        $method = $SupportedMethods[$i]
        $defaultSuffix = if ($method.IsDefault) { ' [default]' } else { '' }
        Write-Host " [$($i + 1)] $($method.Description) ($($method.AuthMethodId))$defaultSuffix"
    }

    while ($true) {
        $selection = Read-Host "Select MFA method [1-$($SupportedMethods.Count)]"
        $index = 0
        if ([int]::TryParse($selection, [ref]$index) -and $index -ge 1 -and $index -le $SupportedMethods.Count) {
            return $SupportedMethods[$index - 1].AuthMethodId
        }

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

#endregion

function Invoke-XdrCredentialAuthentication {
    <#
    .SYNOPSIS
        Performs username/password + optional TOTP authentication against Entra ID and returns the ESTSAUTH cookie value.

    .DESCRIPTION
        Implements the full Entra ID web login flow programmatically: submits credentials to the
        /authorize endpoint, handles MFA challenges via the SAS (Server Authentication State) endpoints,
        and processes interrupt pages (KMSI, CMSI, ConvergedSignIn).

        This is an internal function used by Connect-XdrByCredential.

        Supported MFA methods:
          - PhoneAppOTP: Authenticator app TOTP code (computed automatically from -TotpSecret)
          - PhoneAppNotification: Push notification (polls for user approval, displays number match)
          - OneWaySMS: SMS code (prompts user to enter code from phone)

    .PARAMETER Username
        The user principal name (e.g., admin@contoso.com).

    .PARAMETER Password
        The password as a SecureString. The plain-text value is materialized only
        immediately before it is submitted to the Entra ID sign-in form.

    .PARAMETER TotpSecret
        Base32-encoded TOTP secret for automatic MFA code generation.
        This is the secret from the QR code when setting up Microsoft Authenticator
        (otpauth://totp/...?secret=JBSWY3DPEHPK3PXP).
        If not provided and MFA is required, the function will attempt push notification
        or prompt for a code.

    .PARAMETER MfaMethod
        Preferred MFA method. Valid values: PhoneAppOTP, PhoneAppNotification, OneWaySMS.
        If not specified, PhoneAppOTP is auto-selected only when -TotpSecret is provided and
        that method is actually offered. Otherwise, the function chooses the only supported inline
        method or prompts you when multiple supported inline methods are available.

    .PARAMETER UserAgent
        User-Agent string for HTTP requests.

    .EXAMPLE
        $password = ConvertTo-SecureString "MyPassword" -AsPlainText -Force
        Invoke-XdrCredentialAuthentication -Username "admin@contoso.com" -Password $password -TotpSecret "JBSWY3DPEHPK3PXP"

        Authenticates with a SecureString password and returns the ESTSAUTH cookie value.

    .OUTPUTS
        String - the ESTSAUTH cookie value suitable for passing to Connect-XdrByEstsCookie.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Username,

        [Parameter(Mandatory)]
        [SecureString]$Password,

        [string]$TotpSecret,

        [ValidateSet('PhoneAppOTP', 'PhoneAppNotification', 'OneWaySMS')]
        [string]$MfaMethod,

        [string]$UserAgent = (Get-XdrDefaultUserAgent)
    )

    #region Establish session and initiate authentication flow
    $authUrl = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize" +
    "?response_type=code" +
    "&redirect_uri=msauth.com.msauth.unsignedapp://auth" +
    "&scope=https://graph.microsoft.com/.default" +
    "&client_id=04b07795-8ddb-461a-bbee-02f9e1bf7b46" +
    "&sso_reload=true" +
    "&login_hint=$([uri]::EscapeDataString($Username))"

    $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
    $session.UserAgent = $UserAgent

    Write-Verbose "Initiating authentication flow for $Username..."
    $initialResponse = Invoke-WebRequest -UseBasicParsing -Uri $authUrl -Method Get -WebSession $session -MaximumRedirection 0 -SkipHttpErrorCheck -Verbose:$false

    $sessionInfo = Get-XdrAuthStateFromResponse -Response $initialResponse
    if (-not $sessionInfo) {
        throw "Unexpected response from Entra ID authentication endpoint."
    }

    if (-not $sessionInfo.urlPost) {
        if ($sessionInfo.sErrorCode) {
            throw "Authentication failed with error $($sessionInfo.sErrorCode): $($sessionInfo.sErrTxt)"
        }
        throw "Unexpected response: no urlPost in login page configuration."
    }
    Write-Verbose "Login page loaded (pgid: $($sessionInfo.pgid))"
    #endregion

    #region Submit credentials (type=11 = password)
    Write-Host "Submitting credentials for $Username..."
    $passwordHandle = [IntPtr]::Zero
    $plainPassword = $null

    try {
        $passwordHandle = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
        $plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordHandle)

        $credBody = @{
            login        = $Username
            loginfmt     = $Username
            passwd       = $plainPassword
            type         = 11
            ps           = 2
            LoginOptions = 3
            flowToken    = $sessionInfo.sFT
            ctx          = $sessionInfo.sCtx
            canary       = $sessionInfo.canary
            hpgrequestid = $sessionInfo.correlationId
        }

        $credResponse = Invoke-WebRequest -UseBasicParsing -Method Post `
            -Uri $sessionInfo.urlPost `
            -Body $credBody `
            -WebSession $session -MaximumRedirection 0 -SkipHttpErrorCheck -Verbose:$false
    } finally {
        $plainPassword = $null
        if ($passwordHandle -ne [IntPtr]::Zero) {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordHandle)
        }
    }

    $credOutcome = Resolve-XdrAuthenticationResponse -Response $credResponse -Session $session
    $authState = $credOutcome.AuthState
    if (-not $authState) {
        throw "Unexpected response after credential submission."
    }

    # Check for credential errors
    if ($authState.sErrorCode) {
        $errorMessages = @{
            '50126' = "Invalid username or password."
            '50053' = "Account is locked. Too many failed sign-in attempts."
            '50057' = "Account is disabled."
            '50055' = "Password has expired."
            '50056' = "Invalid or null password."
            '53003' = "Blocked by Conditional Access policy."
            '50034' = "User account not found."
        }
        $msg = $errorMessages[$authState.sErrorCode]
        if (-not $msg) { $msg = $authState.sErrTxt }
        throw "Authentication failed ($($authState.sErrorCode)): $msg"
    }

    Write-Verbose "Credential submission succeeded (pgid: $($authState.pgid))"
    #endregion

    #region Handle MFA challenge (ConvergedTFA)
    if ($authState.pgid -eq 'ConvergedTFA') {
        Write-Host "MFA required."
        $sasHeaders = Get-XdrEstsApiHeaderSet -AuthState $authState

        # Determine MFA method
        $supportedMethods = Get-XdrSupportedMfaOption -AuthState $authState
        if (-not $supportedMethods) {
            $offeredMethods = @($authState.arrUserProofs | ForEach-Object authMethodId | Sort-Object -Unique)
            $offeredMethodsText = if ($offeredMethods) { $offeredMethods -join ', ' } else { 'none returned by service' }
            throw "No supported inline MFA methods were offered for this sign-in. Offered methods: $offeredMethodsText. Use Connect-XdrBySoftwarePasskey for passkey-based methods."
        }

        $selectedMethod = Select-XdrMfaMethod -SupportedMethods $supportedMethods -PreferredMethod $MfaMethod -TotpSecret $TotpSecret

        Write-Host "Using MFA method: $selectedMethod"
        Write-Verbose "Available methods: $(($supportedMethods | ForEach-Object { $_.AuthMethodId }) -join ', ')"

        $beginAuth = Invoke-XdrSasBeginAuth -SelectedMethod $selectedMethod -AuthState $authState -Session $session -Headers $sasHeaders -BeginAuthUri 'https://login.microsoftonline.com/common/SAS/BeginAuth' -FailureLabel 'MFA'

        # Get the verification code based on method
        $verificationCode = $null
        $processAuthPollStart = $null
        $processAuthPollEnd = $null

        switch ($selectedMethod) {
            'PhoneAppOTP' {
                if ($TotpSecret) {
                    $verificationCode = Get-XdrTotpCode -Secret $TotpSecret
                    Write-Verbose "Computed TOTP code: $verificationCode"
                } else {
                    Write-Host "Enter the code from your authenticator app:"
                    $verificationCode = Read-Host "Code"
                }
            }
            'OneWaySMS' {
                Write-Host "An SMS has been sent to your phone."
                Write-Host "Enter the verification code:"
                $verificationCode = Read-Host "Code"
            }
            'PhoneAppNotification' {
                # Push notification - poll for approval
                $entropy = $beginAuth.Entropy
                if ($entropy -and $entropy -gt 0) {
                    Write-Host "Approve the sign-in request in your Authenticator app."
                    Write-Host "Number to match: $entropy" -ForegroundColor Yellow
                } else {
                    Write-Host "Approve the sign-in request in your Authenticator app."
                }

                $pollOutcome = Invoke-XdrSasPushNotificationPolling -SelectedMethod $selectedMethod -BeginAuth $beginAuth -AuthState $authState -Session $session -Headers $sasHeaders -EndAuthUri 'https://login.microsoftonline.com/common/SAS/EndAuth' -Deadline (Get-Date).AddSeconds(180) -FailureLabel 'Push notification' -TimeoutMessage 'Push notification timed out after {0} seconds.'
                $beginAuth = $pollOutcome.BeginAuth
                $processAuthPollStart = $pollOutcome.ProcessAuthPollStart
                $processAuthPollEnd = $pollOutcome.ProcessAuthPollEnd

                Write-Host "Push notification approved."
            }
        }

        # EndAuth - submit verification code (for OTP and SMS methods)
        if ($selectedMethod -ne 'PhoneAppNotification') {
            if (-not $verificationCode) {
                throw "No verification code provided for MFA method $selectedMethod."
            }

            $endBody = @{
                AuthMethodId       = $selectedMethod
                Method             = "EndAuth"
                SessionId          = $beginAuth.SessionId
                FlowToken          = $beginAuth.FlowToken
                Ctx                = $beginAuth.Ctx
                AdditionalAuthData = $verificationCode
                PollCount          = 1
            } | ConvertTo-Json

            Write-Verbose "Calling SAS/EndAuth with verification code..."
            $endAuth = Invoke-RestMethod -Method Post `
                -Uri "https://login.microsoftonline.com/common/SAS/EndAuth" `
                -Body $endBody -ContentType "application/json" `
                -Headers $sasHeaders `
                -WebSession $session -Verbose:$false

            if (-not (Test-XdrMfaAuthSucceeded -Response $endAuth)) {
                $errDetail = if ($endAuth.Message) { $endAuth.Message } else { $endAuth.ResultValue }
                throw "MFA verification failed: $errDetail"
            }

            Write-Host "MFA verification succeeded."
            $beginAuth = $endAuth  # Carry forward FlowToken for ProcessAuth
        }

        $processOutcome = Invoke-XdrSasProcessAuth -SelectedMethod $selectedMethod -Username $Username -BeginAuth $beginAuth -AuthState $authState -Session $session -Headers $sasHeaders -ProcessAuthUri 'https://login.microsoftonline.com/common/SAS/ProcessAuth' -MfaLastPollStart $processAuthPollStart -MfaLastPollEnd $processAuthPollEnd
        $processOutcome = $processOutcome.Outcome
        $authState = $processOutcome.AuthState

        if ($authState) {
            Write-Verbose "ProcessAuth completed (pgid: $($authState.pgid))"
        } else {
            Write-Verbose 'ProcessAuth completed with a non-JSON terminal response.'
        }
    }

    # Handle ConvergedProofUpRedirect (MFA registration prompt - skip it)
    if ($authState -and $authState.pgid -eq 'ConvergedProofUpRedirect') {
        Write-Verbose "MFA registration prompt detected, attempting to skip..."
        if ($authState.iRemainingDaysToSkipMfaRegistration -and $authState.iRemainingDaysToSkipMfaRegistration -gt 0) {
            $skipBody = @{
                type      = 22
                FlowToken = $authState.sFT
                request   = $authState.sProofUpAuthState
                ctx       = $authState.sProofUpAuthState
            } | ConvertTo-Json
            $skipResponse = Invoke-XdrRedirectCapturingWebRequest `
                -Method Post `
                -Uri "https://login.microsoftonline.com/common/SAS/ProcessAuth" `
                -Body $skipBody `
                -ContentType "application/json" `
                -Headers (Get-XdrEstsApiHeaderSet -AuthState $authState) `
                -Session $session

            $skipResponseState = Get-XdrAuthStateFromResponse -Response $skipResponse

            if (Test-XdrProcessAuthRetryableError -ParsedState $skipResponseState) {
                $formSkipBody = @{
                    type         = 22
                    request      = $authState.sProofUpAuthState
                    flowToken    = $authState.sFT
                    ctx          = $authState.sProofUpAuthState
                    canary       = $authState.canary
                    hpgrequestid = $authState.correlationId
                }

                Write-Verbose 'Proof-up skip ProcessAuth returned a retryable request parsing error. Retrying with login-form style field names.'
                $skipResponse = Invoke-XdrRedirectCapturingWebRequest `
                    -Method Post `
                    -Uri "https://login.microsoftonline.com/common/SAS/ProcessAuth" `
                    -Body $formSkipBody `
                    -Session $session

                $skipBody = $formSkipBody | ConvertTo-Json
                $skipResponseState = Get-XdrAuthStateFromResponse -Response $skipResponse
            }

            $skipOutcome = Resolve-XdrAuthenticationResponse -Response $skipResponse -Session $session
            $authState = $skipOutcome.AuthState
        } else {
            throw "MFA registration is required for this account and cannot be skipped."
        }
    }
    #endregion

    #region Handle interrupt pages (CmsiInterrupt, KmsiInterrupt, ConvergedSignIn)
    # This section is identical to the passkey flow interrupt handling
    $debug = $authState

    $interruptHandlers = @{
        "CmsiInterrupt"   = @{
            Uri    = "https://login.microsoftonline.com/appverify"
            Method = "Post"
            Body   = { @{
                    ContinueAuth    = "true"
                    i19             = Get-Random -Minimum 1000 -Maximum 9999
                    canary          = $debug.canary
                    iscsrfspeedbump = "false"
                    flowToken       = $debug.sFT
                    hpgrequestid    = $debug.correlationId
                    ctx             = $debug.sCtx
                } }
        }
        "KmsiInterrupt"   = @{
            Uri    = "https://login.microsoftonline.com/kmsi"
            Method = "Post"
            Body   = { @{
                    LoginOptions = 1
                    type         = 28
                    ctx          = $debug.sCtx
                    hpgrequestid = $debug.correlationId
                    flowToken    = $debug.sFT
                    canary       = $debug.canary
                    i19          = 4130
                } }
        }
        "ConvergedSignIn" = @{
            Uri    = { $sessionId = if ($null -ne $debug.arrSessions -and $null -ne $debug.arrSessions[0].id) { $debug.arrSessions[0].id } else { $debug.sessionId }; "$($debug.urlLogin)&sessionid=$sessionId" }
            Method = "Get"
        }
    }

    $loopCount = 0
    $lastPageId = $null
    $authFailed = $false

    while ($debug -and $debug.pgid -in $interruptHandlers.Keys) {
        $currentPageId = $debug.pgid
        if ($currentPageId -eq $lastPageId -or ++$loopCount -gt 10) {
            $authFailed = $true
            Write-Verbose "Stuck in interrupt loop (lastPageId: $lastPageId, currentPageId: $currentPageId, loopCount: $loopCount)"
            break
        }
        $lastPageId = $currentPageId
        $handler = $interruptHandlers[$currentPageId]
        Write-Verbose "Handling interrupt: $currentPageId"

        $reqParams = @{
            Uri                = if ($handler.Uri -is [scriptblock]) { & $handler.Uri } else { $handler.Uri }
            Method             = $handler.Method
            WebSession         = $session
            UseBasicParsing    = $true
            SkipHttpErrorCheck = $true
            MaximumRedirection = 10
            Verbose            = $false
        }
        if ($handler.Body) { $reqParams.Body = & $handler.Body }

        $respFinalize = Invoke-WebRequest @reqParams
        Start-Sleep -Milliseconds 300

        $interruptOutcome = Resolve-XdrAuthenticationResponse -Response $respFinalize -Session $session
        $debug = $interruptOutcome.AuthState
        if (-not $debug -or -not $debug.pgid) {
            break
        }
    }

    if ($authFailed) {
        throw "Authentication failed: stuck in interrupt page loop. Verify credentials and MFA configuration."
    }
    #endregion

    #region Verify and return ESTSAUTH cookie
    $allCookies = $session.Cookies.GetCookies("https://login.microsoftonline.com")
    Write-Verbose "Cookies present: $($allCookies.Name -join ', ')"

    $estsCookies = $allCookies | Where-Object Name -Like "ESTS*"
    if (-not $estsCookies) {
        throw "Authentication flow completed but no ESTS authentication cookie was obtained. Verify username, password, and MFA configuration."
    }

    # Pick the longest cookie (ESTSAUTHPERSISTENT is preferred when available)
    $bestCookie = @(
        $allCookies | Where-Object Name -EQ "ESTSAUTH"
        $allCookies | Where-Object Name -EQ "ESTSAUTHPERSISTENT"
        $allCookies | Where-Object Name -EQ "ESTSAUTHLIGHT"
    ) | Where-Object { $_ } | Sort-Object { $_.Value.Length } -Descending | Select-Object -First 1

    Write-Verbose "Obtained $($bestCookie.Name) cookie (length: $($bestCookie.Value.Length))"
    return $bestCookie.Value
    #endregion
}