internal/functions/Invoke-XdrPasskeyAuthentication.ps1
|
#region Private Helper Functions function ConvertTo-XdrBase64Url { param([byte[]]$Bytes) return [Convert]::ToBase64String($Bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') } function ConvertFrom-XdrBase64Url { param([string]$Base64Url) $base64 = $Base64Url.Replace('-', '+').Replace('_', '/') $padding = (4 - ($base64.Length % 4)) % 4 $base64 += '=' * $padding return [Convert]::FromBase64String($base64) } function ConvertFrom-XdrUuidToBase64Url { param([string]$Uuid) if ($Uuid -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { return $Uuid } Write-Verbose "Converting UUID format credential ID to base64url" $hexString = $Uuid.Replace('-', '') $rawBytes = [byte[]]::new($hexString.Length / 2) for ($i = 0; $i -lt $hexString.Length; $i += 2) { $rawBytes[$i / 2] = [Convert]::ToByte($hexString.Substring($i, 2), 16) } $base64 = [Convert]::ToBase64String($rawBytes) return $base64.Replace('+', '-').Replace('/', '_').TrimEnd('=') } function ConvertFrom-XdrIeeeToDer { param([byte[]]$IeeeSignature) if ($IeeeSignature.Length -ne 64) { throw "Invalid IEEE P1363 signature length: $($IeeeSignature.Length). Expected 64 bytes for ES256." } $r = $IeeeSignature[0..31] $s = $IeeeSignature[32..63] while ($r.Length -gt 1 -and $r[0] -eq 0) { $r = $r[1..($r.Length - 1)] } while ($s.Length -gt 1 -and $s[0] -eq 0) { $s = $s[1..($s.Length - 1)] } if ($r[0] -ge 0x80) { $r = @(0) + $r } if ($s[0] -ge 0x80) { $s = @(0) + $s } $der = @(0x30, ($r.Length + $s.Length + 4), 0x02, $r.Length) + $r + @(0x02, $s.Length) + $s return [byte[]]$der } function ConvertTo-XdrPEMPrivateKey { param([string]$PrivateKey) if ($PrivateKey.Trim() -match "^-----BEGIN PRIVATE KEY-----") { return $PrivateKey } $cleanKey = $PrivateKey.Trim() -replace "`r|`n|\s", "" $cleanKey = $cleanKey -replace "-", "+" -replace "_", "/" $wrappedKey = "" for ($i = 0; $i -lt $cleanKey.Length; $i += 64) { if ($i + 64 -lt $cleanKey.Length) { $wrappedKey += $cleanKey.Substring($i, 64) + "`n" } else { $wrappedKey += $cleanKey.Substring($i) } } return "-----BEGIN PRIVATE KEY-----`n$wrappedKey`n-----END PRIVATE KEY-----" } function New-XdrPasskeyAuthenticatorData { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private helper for passkey data construction, not a state-changing cmdlet')] param( [Parameter(Mandatory)][string]$RpId, [int]$SignCount = 0, [byte]$Flags = 0x05 ) $rpIdBytes = [System.Text.Encoding]::UTF8.GetBytes($RpId) $rpIdHash = [System.Security.Cryptography.SHA256]::HashData($rpIdBytes) $cntBytes = [BitConverter]::GetBytes([int]$SignCount) if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($cntBytes) } $authData = [byte[]]::new(37) [Array]::Copy($rpIdHash, 0, $authData, 0, 32) $authData[32] = $Flags [Array]::Copy($cntBytes, 0, $authData, 33, 4) return $authData } function New-XdrPasskeySignature { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private helper for passkey signature generation, not a state-changing cmdlet')] param( [Parameter(Mandatory)][string]$Challenge, [Parameter(Mandatory)][string]$Origin, [Parameter(Mandatory)][byte[]]$AuthDataBytes, [string]$PrivateKeyPem, $KeyVaultInfo, [string]$KeyVaultToken, [string]$KeyVaultApiVersion = '7.4' ) $clientData = [ordered]@{ challenge = $Challenge crossOrigin = $false origin = $Origin type = "webauthn.get" } $clientJson = $clientData | ConvertTo-Json -Compress -Depth 10 $clientBytes = [System.Text.Encoding]::UTF8.GetBytes($clientJson) $clientHash = [System.Security.Cryptography.SHA256]::HashData($clientBytes) $dataToSign = $AuthDataBytes + $clientHash $dataHash = [System.Security.Cryptography.SHA256]::HashData($dataToSign) Write-Verbose "Data to sign: $($dataToSign.Length) bytes, pre-hashed to $($dataHash.Length) bytes" if ($KeyVaultInfo -and $KeyVaultToken) { Write-Verbose "Signing with Azure Key Vault ($($KeyVaultInfo.vaultName)/$($KeyVaultInfo.keyName), api-version=$KeyVaultApiVersion)" $dataBase64Url = ConvertTo-XdrBase64Url -Bytes $dataHash $signUri = "https://$($KeyVaultInfo.vaultName).vault.azure.net/keys/$($KeyVaultInfo.keyName)/sign?api-version=$KeyVaultApiVersion" $kvHeaders = @{ "Authorization" = "Bearer $KeyVaultToken"; "Content-Type" = "application/json" } $body = @{ alg = "ES256"; value = $dataBase64Url } | ConvertTo-Json $maxRetries = 3 $retryDelay = 1000 $sigBytes = $null for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { try { Write-Verbose "Key Vault sign attempt $attempt of $maxRetries..." $result = Invoke-RestMethod -Uri $signUri -Method POST -Headers $kvHeaders -Body $body -ErrorAction Stop if (-not $result.value) { throw "Key Vault returned empty signature" } $ieeeSignature = ConvertFrom-XdrBase64Url -Base64Url $result.value if ($ieeeSignature.Length -ne 64) { Write-Warning "Unexpected IEEE signature length: $($ieeeSignature.Length) bytes (expected 64)" } $sigBytes = ConvertFrom-XdrIeeeToDer -IeeeSignature $ieeeSignature Write-Verbose "Key Vault signing succeeded (DER: $($sigBytes.Length) bytes)" break } catch { Write-Warning "Key Vault sign attempt $attempt failed: $($_.Exception.Message)" if ($attempt -lt $maxRetries) { Start-Sleep -Milliseconds $retryDelay $retryDelay *= 2 } else { throw "Key Vault signing failed after $maxRetries attempts: $($_.Exception.Message)" } } } if (-not $sigBytes) { throw "Key Vault signing failed: no signature generated" } } else { Write-Verbose "Signing with local private key" $ecdsa = [System.Security.Cryptography.ECDsa]::Create() try { $ecdsa.ImportFromPem($PrivateKeyPem) $sigBytes = $ecdsa.SignHash($dataHash, [System.Security.Cryptography.DSASignatureFormat]::Rfc3279DerSequence) } finally { $ecdsa.Dispose() } Write-Verbose "Local signing succeeded (DER: $($sigBytes.Length) bytes)" } return @{ Signature = $sigBytes; ClientData = $clientBytes } } function Get-XdrKeyVaultAccessToken { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] param( [string]$KeyVaultTenantId, [string]$KeyVaultClientId ) $resource = "https://vault.azure.net" $modeDescription = if ($KeyVaultClientId) { "user-assigned managed identity (client_id: $KeyVaultClientId)" } else { "system-assigned managed identity" } # 1. Try Az module (Az.Accounts) if (Get-Command Get-AzAccessToken -ErrorAction SilentlyContinue) { Write-Verbose "Az.Accounts module detected, attempting Get-AzAccessToken..." try { $azParams = @{ ResourceUrl = $resource } if ($KeyVaultTenantId) { $azParams.TenantId = $KeyVaultTenantId } $azToken = Get-AzAccessToken @azParams -ErrorAction Stop $tokenValue = if ($azToken.Token -is [System.Security.SecureString]) { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR( [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($azToken.Token) ) } else { $azToken.Token } Write-Verbose "Successfully obtained Key Vault token via Az module" return $tokenValue } catch { Write-Verbose "Az module token failed (not logged in or expired): $($_.Exception.Message)" } } else { Write-Verbose "Az.Accounts module not loaded, skipping" } # 2. Try Azure CLI if (Get-Command az -ErrorAction SilentlyContinue) { Write-Verbose "Azure CLI detected, attempting az account get-access-token..." try { $azCliArgs = @("account", "get-access-token", "--resource", $resource, "--output", "json") if ($KeyVaultTenantId) { $azCliArgs += @("--tenant", $KeyVaultTenantId) } $azCliOutput = & az @azCliArgs 2>&1 if ($LASTEXITCODE -eq 0) { $azCliToken = ($azCliOutput | Out-String) | ConvertFrom-Json Write-Verbose "Successfully obtained Key Vault token via Azure CLI" return $azCliToken.accessToken } else { Write-Verbose "Azure CLI token failed (not logged in): $azCliOutput" } } catch { Write-Verbose "Azure CLI token attempt failed: $($_.Exception.Message)" } } else { Write-Verbose "Azure CLI (az) not found on PATH, skipping" } # 3. Try IMDS (managed identity) # Not providing -KeyVaultClientId uses system-assigned MI. # Providing -KeyVaultClientId uses user-assigned MI with that client ID. Write-Verbose "Attempting IMDS managed identity ($modeDescription)..." try { $imdsUrl = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=$([uri]::EscapeDataString($resource))" if ($KeyVaultClientId) { $imdsUrl += "&client_id=$([uri]::EscapeDataString($KeyVaultClientId))" } $imdsResponse = Invoke-RestMethod -Uri $imdsUrl -Headers @{ Metadata = "true" } -TimeoutSec 3 -ErrorAction Stop Write-Verbose "Successfully obtained Key Vault token via IMDS ($modeDescription)" return $imdsResponse.access_token } catch { Write-Verbose "IMDS token failed (not running in Azure or no managed identity): $($_.Exception.Message)" } # All methods failed throw @" Could not obtain an Azure Key Vault access token. Ensure one of the following: * Run Connect-AzAccount (Az.Accounts module) before calling this cmdlet * Sign in with Azure CLI: az login * Run this cmdlet from an Azure resource with a managed identity assigned * Provide -KeyVaultClientId for a user-assigned managed identity "@ } #endregion # JSON credential file format: # # Local passkey (private key embedded in file): # { # "credentialId": "<base64url or UUID>", Required: FIDO2 credential ID # "privateKey": "-----BEGIN PRIVATE KEY...", Required: EC private key in PEM format # "userHandle": "<base64url>", Required: FIDO2 user handle # "username": "user@domain.com", Required: user principal name # "relyingParty": "login.microsoft.com", Optional: defaults to login.microsoft.com # "url": "https://login.microsoft.com" Optional: authentication server URL # } # # Azure Key Vault passkey (private key secured in HSM): # { # "credentialId": "<base64url or UUID>", Required: FIDO2 credential ID # "keyVault": { Required: replaces privateKey # "vaultName": "kv-name", Required: Azure Key Vault name # "keyName": "key-name", Required: key name within the vault # "keyId": "https://..." Optional: full key ID URL (informational) # }, # "userHandle": "<base64url>", Required: FIDO2 user handle # "username": "user@domain.com", Required: user principal name # "relyingParty": "login.microsoft.com", Optional: defaults to login.microsoft.com # "url": "https://login.microsoft.com" Optional: authentication server URL # } # # Legacy field aliases accepted: userName -> username, rpId -> relyingParty, # methodId -> credentialId, keyValue -> privateKey, counter -> signCount function Invoke-XdrPasskeyAuthentication { <# .SYNOPSIS Performs FIDO2 passkey authentication against Entra ID and returns the ESTSAUTH cookie value. .DESCRIPTION Implements the FIDO2 WebAuthn authentication flow against Microsoft Entra ID using a software passkey credential. Supports both local passkeys (PEM private key in JSON) and Azure Key Vault backed passkeys (private key secured in HSM, referenced by vault/key name in JSON). For Key Vault passkeys, a Bearer token for vault.azure.net is obtained automatically via (in order): Az module (Get-AzAccessToken), Azure CLI (az account get-access-token), or IMDS managed identity. Providing -KeyVaultClientId selects user-assigned managed identity for IMDS; omitting it uses system-assigned managed identity. This is an internal function used by Connect-XdrBySoftwarePasskey. Requires PowerShell 7.0 or later for ECDsa PEM key support. .PARAMETER KeyFilePath Path to a JSON credential file. See the schema comment above this function for valid formats. .PARAMETER KeyVaultTenantId Azure AD tenant ID to scope the Key Vault access token. Used with Az module or Azure CLI. Not required when using IMDS managed identity. .PARAMETER KeyVaultClientId Client ID of a user-assigned managed identity for Key Vault access via IMDS. When not provided and IMDS is used, the system-assigned managed identity is used instead. .PARAMETER KeyVaultApiVersion Azure Key Vault REST API version to use for the Sign operation. Defaults to '7.4'. Update this if a newer stable API version is available and required. .PARAMETER UserAgent User-Agent string for HTTP requests. .EXAMPLE $estsAuth = Invoke-XdrPasskeyAuthentication -KeyFilePath ".github\secadmin.passkey" Connect-XdrByEstsCookie -EstsAuthCookieValue $estsAuth Performs passkey authentication with a local key and uses the result to connect. .OUTPUTS String — the ESTSAUTH cookie value suitable for passing to Connect-XdrByEstsCookie. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$KeyFilePath, [string]$KeyVaultTenantId, [string]$KeyVaultClientId, [string]$KeyVaultApiVersion = '7.4', [string]$UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0' ) #region Validate PowerShell version if ($PSVersionTable.PSVersion.Major -lt 7) { throw "Passkey authentication requires PowerShell 7 or later (for ECDsa PEM support). Current version: $($PSVersionTable.PSVersion)" } #endregion #region Load credential file if (-not (Test-Path $KeyFilePath)) { throw "Credential file not found: $KeyFilePath" } Write-Verbose "Loading credential file: $KeyFilePath" try { $keyData = Get-Content $KeyFilePath -Raw | ConvertFrom-Json } catch { throw "Invalid JSON in credential file '$KeyFilePath': $($_.Exception.Message)" } $targetUser = if ($null -ne $keyData.username) { $keyData.username } else { $keyData.userName } if (-not $targetUser) { throw "Credential file is missing 'username' or 'userName' field" } $rpId = $keyData.relyingParty if ($null -eq $rpId) { $rpId = $keyData.rpId } if ($null -eq $rpId) { $rpId = "login.microsoft.com" } $rawUrl = if ($null -ne $keyData.url) { $keyData.url } else { "https://$rpId" } $origin = "https://$([uri]$rawUrl | Select-Object -ExpandProperty Host)" $userHandle = $keyData.userHandle if (-not $userHandle) { throw "Credential file is missing 'userHandle' field" } $userHandle = $userHandle.TrimEnd('=') -replace '\+', '-' -replace '/', '_' $credentialId = if ($null -ne $keyData.credentialId) { $keyData.credentialId } else { $keyData.methodId } if (-not $credentialId) { throw "Credential file is missing 'credentialId' field" } $credentialId = ($credentialId.TrimEnd('=') -replace '\+', '-' -replace '/', '_') | ForEach-Object { ConvertFrom-XdrUuidToBase64Url $_ } Write-Verbose "User: $targetUser | RP ID: $rpId | Origin: $origin" Write-Verbose "Credential ID: $($credentialId.Substring(0, [Math]::Min(20, $credentialId.Length)))..." #endregion #region Determine signing mode and prepare credentials $useKeyVault = $null -ne $keyData.keyVault $kvInfo = $null $kvToken = $null $privateKeyPem = $null $signCount = $keyData.signCount if ($null -eq $signCount) { $signCount = $keyData.counter } if ($null -eq $signCount) { $signCount = 0 } $signCount = [int]$signCount if ($useKeyVault) { Write-Verbose "Key Vault passkey detected (vault: $($keyData.keyVault.vaultName), key: $($keyData.keyVault.keyName))" $kvInfo = @{ vaultName = $keyData.keyVault.vaultName keyName = $keyData.keyVault.keyName keyId = $keyData.keyVault.keyId } Write-Verbose "Obtaining Key Vault access token..." $kvToken = Get-XdrKeyVaultAccessToken -KeyVaultTenantId $KeyVaultTenantId -KeyVaultClientId $KeyVaultClientId Write-Verbose "Key Vault access token obtained" } else { Write-Verbose "Local passkey detected" $privateKeySource = if ($null -ne $keyData.privateKey) { $keyData.privateKey } else { $keyData.keyValue } if (-not $privateKeySource) { throw "Credential file is missing 'privateKey' field (required for local passkeys)" } try { $privateKeyPem = ConvertTo-XdrPEMPrivateKey -PrivateKey $privateKeySource } catch { throw "Failed to parse private key from credential file: $($_.Exception.Message)" } } #endregion #region Establish session and initiate FIDO2 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($targetUser))" $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession $session.UserAgent = $UserAgent Write-Verbose "Initiating authentication flow for $targetUser..." $initialResponse = Invoke-WebRequest -UseBasicParsing -Uri $authUrl -Method Get -WebSession $session -MaximumRedirection 0 -SkipHttpErrorCheck -Verbose:$false if (-not ($initialResponse.Content -match '{(.*)}')) { throw "Unexpected response from Entra ID authentication endpoint. The auth URL may have changed or the account is not configured for FIDO2." } $sessionInfo = $Matches[0] | ConvertFrom-Json if (-not $sessionInfo.oGetCredTypeResult.Credentials.HasFido -or -not $sessionInfo.sFidoChallenge) { $hasFido = $sessionInfo.oGetCredTypeResult.Credentials.HasFido $hasChallenge = [bool]$sessionInfo.sFidoChallenge throw "Passkey authentication not available for '$targetUser'. HasFido: $hasFido, Challenge present: $hasChallenge. Verify the account has a passkey registered." } $serverChallenge = [System.Text.Encoding]::ASCII.GetBytes($sessionInfo.sFidoChallenge) Write-Verbose "Passkey challenge received" #endregion #region Generate passkey assertion Write-Verbose "Generating passkey authenticator data and signature..." $authData = New-XdrPasskeyAuthenticatorData -RpId $rpId -SignCount $signCount $cryptoParams = @{ Challenge = ConvertTo-XdrBase64Url -Bytes $serverChallenge Origin = $origin AuthDataBytes = $authData KeyVaultApiVersion = $KeyVaultApiVersion } if ($useKeyVault) { $cryptoParams.KeyVaultInfo = $kvInfo $cryptoParams.KeyVaultToken = $kvToken } else { $cryptoParams.PrivateKeyPem = $privateKeyPem } try { $crypto = New-XdrPasskeySignature @cryptoParams } catch { throw "Passkey assertion generation failed: $($_.Exception.Message)" } $fidoPayload = [ordered]@{ id = $credentialId clientDataJSON = ConvertTo-XdrBase64Url -Bytes $crypto.ClientData authenticatorData = ConvertTo-XdrBase64Url -Bytes $authData signature = ConvertTo-XdrBase64Url -Bytes $crypto.Signature userHandle = $userHandle } $credentialsJson = $sessionInfo.oGetCredTypeResult.Credentials.FidoParams.AllowList -join ',' Write-Verbose "Passkey assertion generated successfully" #endregion #region Submit pre-verification request Write-Verbose "Submitting pre-verification request..." $verifyUrl = "https://login.microsoft.com/common/fido/get?uiflavor=Web" $bodyVerify = @{ allowedIdentities = 2 canary = $sessionInfo.sFT ServerChallenge = $sessionInfo.sFT postBackUrl = $sessionInfo.urlPost postBackUrlAad = $sessionInfo.urlPostAad postBackUrlMsa = $sessionInfo.urlPostMsa cancelUrl = $sessionInfo.urlRefresh resumeUrl = $sessionInfo.urlResume correlationId = $sessionInfo.correlationId credentialsJson = $credentialsJson ctx = $sessionInfo.sCtx username = $targetUser loginCanary = $sessionInfo.canary } try { $respVerify = Invoke-WebRequest -UseBasicParsing -Uri $verifyUrl -Method Post -Body $bodyVerify -WebSession $session -MaximumRedirection 0 -SkipHttpErrorCheck -Verbose:$false if ($respVerify.StatusCode -ge 400) { throw "Pre-verification failed with HTTP $($respVerify.StatusCode)" } if (-not ($respVerify.Content -match '{(.*)}')) { throw "Unexpected response format from pre-verification endpoint" } $responseInfo = $Matches[0] | ConvertFrom-Json Write-Verbose "Pre-verification completed" } catch { throw "Pre-verification request failed: $($_.Exception.Message)" } #endregion #region Submit passkey assertion $loginUri = "https://login.microsoftonline.com/common/login" $payload = @{ type = 23 ps = 23 assertion = ($fidoPayload | ConvertTo-Json -Compress -Depth 10) lmcCanary = $responseInfo.sCrossDomainCanary hpgrequestid = $responseInfo.sessionId ctx = $responseInfo.sCtx canary = $responseInfo.canary flowToken = $responseInfo.sFT } Write-Verbose "Submitting passkey assertion..." $null = Invoke-WebRequest -UseBasicParsing -Uri $loginUri -Method Post -Body $payload -WebSession $session -MaximumRedirection 0 -SkipHttpErrorCheck -Verbose:$false if ($useKeyVault) { Start-Sleep -Milliseconds 500 } # SSO reload $loginUri = "https://login.microsoftonline.com/common/login?sso_reload=true" $payload.flowToken = $sessionInfo.oGetCredTypeResult.FlowToken Write-Verbose "Submitting SSO reload..." $respFinalize = Invoke-WebRequest -UseBasicParsing -Uri $loginUri -Method Post -Body $payload -WebSession $session -MaximumRedirection 0 -SkipHttpErrorCheck -Verbose:$false if ($useKeyVault) { Start-Sleep -Milliseconds 500 } #endregion #region Handle interrupt pages (CmsiInterrupt, KmsiInterrupt, ConvergedSignIn) $debug = $null if ($respFinalize.Content -match '{(.*)}') { try { $debug = $Matches[0] | ConvertFrom-Json } catch { $debug = $null } } $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 $debug = $null if ($respFinalize.Content -match '{(.*)}') { try { $debug = $Matches[0] | ConvertFrom-Json if (-not $debug.pgid) { break } # No page ID means interrupts are done } catch { break } } else { break } } if ($authFailed) { $hint = if ($useKeyVault) { "Key Vault signature validation failed. Verify Key Vault permissions (Crypto User / Sign), key name, and vault name." } else { "Passkey signature validation failed. Verify the credential ID and private key in the credential file." } throw "Authentication failed during passkey validation. $hint" } #endregion if ($useKeyVault) { Start-Sleep -Milliseconds 500 } #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. The passkey credentials may be invalid or expired." } # 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 } |