Private/NPSExtension.ps1
|
<#
.SYNOPSIS Option 2 - download/install/configure/uninstall the NPS Extension for Azure MFA, plus prereq checks. Status display (installed?, cert, expiry) already lives in Get-NPSStatus/Show-NPSStatus (Modules\NPSCore.ps1 / NPS-Manager.ps1) - this module is the ACTION side (the maintainer's original spec, verified live against Microsoft's docs earlier this session, not re-guessed here): Download page (no stable direct-download URL - Download Center landing page only, confirmed live; Microsoft generates a single-use download.microsoft.com token URL via page JavaScript after the tech clicks through, so there's nothing fixed to hardcode): https://www.microsoft.com/en-us/download/details.aspx?id=54688 -> click Download -> select "NpsExtnForAzureMfaInstaller.exe" The maintainer built a headless-Selenium automation (Samples\NPSExtension_FullyWorkable_Downloader.ps1) that drives that same click-through in a headless Edge/Chrome session, scrapes the generated token URL out of the resulting page source, then downloads natively via Invoke-WebRequest - adapted below (Invoke-NPSExtensionAutoDownload) as the preferred path when Selenium + a webdriver are available, falling back to Open-NPSExtensionDownloadPage's manual flow otherwise (or on any failure) - this remains inherently a bit fragile (depends on Microsoft's page structure/CSS selectors not changing), which is exactly why the manual fallback always stays available rather than being replaced outright. Prereqs: .NET Framework 4.7.2+, PowerShell 5.1+ TLS: [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Config script (installed by the .exe above): C:\Program Files\Microsoft\AzureMfa\Config\AzureMfaNpsExtnConfigSetup.ps1 Uninstall: find + invoke the real UninstallString (confirmed design decision - not a hardcoded guess at the product's registry GUID, which could easily drift across extension versions; a wildcard DisplayName search is version-independent). .DESCRIPTION Requires Modules\NPSCore.ps1 to already be dot-sourced (uses Get-NPSStatus). #> # --------------------------------------------------------------------------- function Test-NPSExtensionPrereqs { <# .SYNOPSIS Checks the two documented prereqs for the NPS Extension installer: .NET Framework 4.7.2+ and PowerShell 5.1+. .DESCRIPTION .NET Framework version is read from the Release DWORD at HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full - 461808 is Microsoft's own documented minimum threshold for "4.7.2 or later" (461814 is the exact 4.7.2 release key; 461808 is the official comparison floor per Microsoft's version-detection docs, not a guessed number). #> $ndpPath = 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' $release = $null if (Test-Path $ndpPath) { $release = (Get-ItemProperty -Path $ndpPath -Name Release -ErrorAction SilentlyContinue).Release } $dotNetOk = ($null -ne $release) -and ($release -ge 461808) $psOk = $PSVersionTable.PSVersion -ge [version]'5.1' return [pscustomobject]@{ DotNetReleaseValue = $release DotNetOk = $dotNetOk DotNetLabel = if ($null -eq $release) { 'Not detected' } elseif ($dotNetOk) { ".NET 4.7.2+ (release $release)" } else { "Below 4.7.2 (release $release) - update required" } PSVersion = $PSVersionTable.PSVersion PSOk = $psOk AllOk = $dotNetOk -and $psOk } } # --------------------------------------------------------------------------- # Session-lifetime cache for Get-NPSExtensionUninstallInfo, ON TOP OF the fast-scan rewrite below - # belt and suspenders: Get-NPSStatus calls this on every status computation, which runs on every # dashboard menu redraw AND again inside Invoke-ManageNPSExtension's own redraw, so avoiding repeat # work still matters even once each individual scan is fast. Only Clear-NPSExtensionUninstallInfoCache # (called after install/uninstall actions that could actually change the answer) forces a fresh scan. $script:NPSExtensionUninstallInfoCache = $null $script:NPSExtensionUninstallInfoCached = $false function Get-NPSExtensionUninstallInfo { <# .SYNOPSIS Finds the NPS Extension's real Programs-and-Features uninstall entry by DisplayName pattern (NOT a hardcoded product GUID, which could easily differ across extension versions/updates) - searches both the native and Wow6432Node Uninstall registry hives. Returns $null if not found (that's a normal, expected outcome - not an error - for a machine where it's simply not installed via MSI, e.g. if HKLM:\SOFTWARE\Microsoft\AzureMfa exists from a manual/partial setup). Uses raw Microsoft.Win32.Registry APIs (GetSubKeyNames + a single targeted GetValue per subkey), NOT Get-ItemProperty against a wildcarded path - confirmed live (the maintainer) that the wildcard-expansion approach (which reads EVERY property of EVERY installed program's Uninstall subkey through PowerShell's registry provider) measured 1.2-1.5 SECONDS on a machine with ~550 installed-software entries, and was still hanging the dashboard's very first status check on the maintainer's real server (session-caching alone doesn't help a cold first call). The raw-API rewrite measured 25x faster (~50ms) for the identical result on the same machine - reads only subkey names (cheap) plus one value (DisplayName) per subkey, versus every property PowerShell's provider round-trips for each entry. Cached for the session on top of that - see the module-scoped cache notes above. Pass -Force to bypass the cache and re-scan (e.g. right after an uninstall you just ran, before the cache would naturally be invalidated). #> param([switch]$Force) if ($script:NPSExtensionUninstallInfoCached -and -not $Force) { return $script:NPSExtensionUninstallInfoCache } $hives = @( 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' 'SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' ) $result = $null foreach ($hivePath in $hives) { $hiveKey = $null try { $hiveKey = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($hivePath) if (-not $hiveKey) { continue } foreach ($subKeyName in $hiveKey.GetSubKeyNames()) { $subKey = $null try { $subKey = $hiveKey.OpenSubKey($subKeyName) if (-not $subKey) { continue } $displayName = $subKey.GetValue('DisplayName') if ($displayName -and ($displayName -like '*NPS Extension*' -or $displayName -like '*Azure Multi-Factor*Authentication*NPS*')) { $result = [pscustomobject]@{ DisplayName = $displayName DisplayVersion = $subKey.GetValue('DisplayVersion') UninstallString = $subKey.GetValue('UninstallString') QuietUninstallString = $subKey.GetValue('QuietUninstallString') RegistryPath = "HKLM:\$hivePath\$subKeyName" } break } } finally { if ($subKey) { $subKey.Dispose() } } } } catch { # Best-effort, same as the old -ErrorAction SilentlyContinue behavior - a permission # issue or transient registry error on one hive shouldn't abort the whole lookup. } finally { if ($hiveKey) { $hiveKey.Dispose() } } if ($result) { break } } $script:NPSExtensionUninstallInfoCache = $result $script:NPSExtensionUninstallInfoCached = $true return $result } # --------------------------------------------------------------------------- function Clear-NPSExtensionUninstallInfoCache { <# .SYNOPSIS Invalidates Get-NPSExtensionUninstallInfo's session cache - call after any action that could actually change whether/how the extension shows up in Programs and Features (install, update, uninstall). Cheap/safe to call even when nothing changed; the next Get-NPSExtensionUninstallInfo call just re-scans once. #> $script:NPSExtensionUninstallInfoCached = $false $script:NPSExtensionUninstallInfoCache = $null } # --------------------------------------------------------------------------- function Invoke-NPSExtensionUninstall { <# .SYNOPSIS Actually runs the uninstall, and VERIFIES it worked afterward rather than assuming success from the invoking process merely exiting - confirmed live (the maintainer) that a naive "Start-Process cmd.exe /c $UninstallString" could report success without actually removing anything. .DESCRIPTION Two things fixed vs. the naive approach: - Runs the uninstaller DIRECTLY (Start-Process -FilePath <exe> -ArgumentList <args>), not wrapped in "cmd.exe /c <string>". Wrapping through cmd is fragile for exactly the strings these commonly are - an MSI UninstallString like 'MsiExec.exe /X{GUID}' or a quoted EXE path with its own arguments - Start-Process's own array-based -ArgumentList quoting can disagree with how cmd.exe re-parses a single already-quoted string handed to it, silently producing a no-op or a differently-scoped command than intended. - MSI-based UninstallStrings are detected (msiexec + a {GUID}) and run with /qn /norestart added explicitly (unattended - a UI-mode msiexec can otherwise block waiting for input that never comes in this context) and via msiexec.exe directly rather than parsing msiexec's own quoting. - QuietUninstallString is preferred over UninstallString when the registry entry provides one - already pre-built for unattended use by whoever authored the installer, more reliable than us guessing at silent flags for a generic EXE-based uninstaller. - After running, RE-CHECKS Get-NPSExtensionUninstallInfo (not just the exit code) and reports Verified=$true only if the Programs-and-Features entry is actually gone - the exit code alone isn't trustworthy enough to claim success against, given the above. #> param([Parameter(Mandatory)]$UninstallInfo) $commandString = if ($UninstallInfo.QuietUninstallString) { $UninstallInfo.QuietUninstallString } else { $UninstallInfo.UninstallString } $usedQuietVariant = [bool]$UninstallInfo.QuietUninstallString $msiMatch = [regex]::Match($commandString, 'msiexec(\.exe)?\b.*?(\{[0-9A-Fa-f\-]{36}\})', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) $exitCode = $null try { if ($msiMatch.Success) { $productCode = $msiMatch.Groups[2].Value # Confirmed against Microsoft's own msiexec docs (e.g. their /fa example uses # "msiexec.exe /fa {GUID}") - /X and the product code are SEPARATE arguments joined by a # space, not one concatenated token. Concatenating them ("/X{GUID}") is malformed: msiexec # silently no-ops under /qn (all UI, including its own "invalid args" complaint, is # suppressed - so it reports exit 0 without touching anything) and shows the bare # usage/help screen under /passive instead (that screen is the command-line PARSER # failing, before /passive's install-progress UI even comes into play) - both confirmed # live (the maintainer) against this exact bug. $msiArgs = @("/X", $productCode, "/qn", "/norestart") Write-Host "Running: msiexec.exe $($msiArgs -join ' ')" -ForegroundColor Cyan $proc = Start-Process -FilePath 'msiexec.exe' -ArgumentList $msiArgs -Wait -PassThru -NoNewWindow $exitCode = $proc.ExitCode } else { # Split the leading executable (quoted or not) from its own arguments, rather than # re-quoting the whole string through cmd.exe. $parsed = [regex]::Match($commandString, '^\s*"([^"]+)"\s*(.*)$') if ($parsed.Success) { $exePath = $parsed.Groups[1].Value $exeArgs = $parsed.Groups[2].Value } else { $split = $commandString.Split(' ', 2) $exePath = $split[0] $exeArgs = if ($split.Count -gt 1) { $split[1] } else { '' } } Write-Host "Running: `"$exePath`" $exeArgs" -ForegroundColor Cyan $proc = if ($exeArgs) { Start-Process -FilePath $exePath -ArgumentList $exeArgs -Wait -PassThru -NoNewWindow } else { Start-Process -FilePath $exePath -Wait -PassThru -NoNewWindow } $exitCode = $proc.ExitCode } } catch { return [pscustomobject]@{ Verified = $false; ExitCode = $exitCode; UsedQuietVariant = $usedQuietVariant; Error = $_.Exception.Message } } Start-Sleep -Seconds 2 # Programs-and-Features registry cleanup can lag a moment behind the process exiting # -Force is NOT optional here - without it this would return the CACHED pre-uninstall result and # always report Verified=$false regardless of what actually happened, making this check useless. $stillPresent = Get-NPSExtensionUninstallInfo -Force return [pscustomobject]@{ Verified = ($null -eq $stillPresent) ExitCode = $exitCode UsedQuietVariant = $usedQuietVariant Error = $null } } # --------------------------------------------------------------------------- function Remove-NPSExtensionOrphanedConfig { <# .SYNOPSIS Removes HKLM:\SOFTWARE\Microsoft\AzureMfa when it's confirmed orphaned - the extension was genuinely uninstalled (no Programs-and-Features entry) but the uninstaller left this key behind, which otherwise makes Get-NPSStatus/this dashboard keep reporting stale installed/config state indefinitely. Refuses to touch it if Get-NPSExtensionUninstallInfo still finds a real install, so this can't be used to blow away a legitimately-installed extension's config by mistake. #> if (Get-NPSExtensionUninstallInfo) { throw "Refusing to remove HKLM:\SOFTWARE\Microsoft\AzureMfa - Programs and Features still shows the extension installed. Uninstall it properly first." } $path = 'HKLM:\SOFTWARE\Microsoft\AzureMfa' if (-not (Test-Path $path)) { return [pscustomobject]@{ Removed = $false; AlreadyAbsent = $true } } Remove-Item -Path $path -Recurse -Force return [pscustomobject]@{ Removed = $true; AlreadyAbsent = $false } } # --------------------------------------------------------------------------- function Get-NPSInstalledEdgeVersion { <# .SYNOPSIS Reads the actually-installed Microsoft Edge browser's version from the exe's own file version info (Program Files or Program Files (x86)) - NOT HKLM:\SOFTWARE\Microsoft\Edge\BLBeacon's "version" value, which was confirmed EMPTY on a real test machine that otherwise has Edge installed (BLBeacon appears to only populate after Edge has actually run in a session - unreliable as a primary source). Returns $null if Edge isn't found at either standard path. #> $edgeExePaths = @( "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe" "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" ) $edgeExe = $edgeExePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $edgeExe) { return $null } return (Get-Item $edgeExe).VersionInfo.ProductVersion } # --------------------------------------------------------------------------- function Test-NPSEdgeDriverVersionMatch { <# .SYNOPSIS Checks whether a webdriver executable's own reported version matches the installed Edge browser's MAJOR version - confirmed live (the maintainer) that a driver merely EXISTING isn't enough to trust: a stale driver (a leftover v79 - the very first Chromium-Edge release - was found sitting in a real Selenium assemblies folder) produced Selenium's own "session not created: Microsoft Edge version must be 79" failure, since it was never version-checked before being used, just detected as present. Runs "$DriverPath --version" and regex-extracts the version number, rather than trusting the driver file's own VersionInfo - confirmed live this works identically for both real msedgedriver.exe output ("Microsoft Edge WebDriver 151.0.4129.59 (...)") and a chromedriver.exe standing in for it ("ChromeDriver ..." - same numbering scheme, Chromium release cadence tracks closely enough between the two for automation purposes). Compares MAJOR version only (the first dot-segment) - that's the actual compatibility boundary Selenium's error message itself references ("version must be 79"), not an exact 4-part match. #> param([Parameter(Mandatory)][string]$DriverPath) if (-not (Test-Path $DriverPath)) { return [pscustomobject]@{ Match = $false; Reason = "Driver not found at $DriverPath"; DriverVersion = $null; EdgeVersion = $null } } $edgeVersion = Get-NPSInstalledEdgeVersion if (-not $edgeVersion) { return [pscustomobject]@{ Match = $false; Reason = "Microsoft Edge not found - can't determine what version to compare against"; DriverVersion = $null; EdgeVersion = $null } } try { $versionOutput = & $DriverPath --version 2>&1 | Out-String } catch { return [pscustomobject]@{ Match = $false; Reason = "Could not run '$DriverPath --version' - $($_.Exception.Message)"; DriverVersion = $null; EdgeVersion = $edgeVersion } } $versionMatch = [regex]::Match($versionOutput, '\d+\.\d+\.\d+\.\d+') if (-not $versionMatch.Success) { return [pscustomobject]@{ Match = $false; Reason = "Could not parse a version number from driver output: $versionOutput"; DriverVersion = $null; EdgeVersion = $edgeVersion } } $driverVersion = $versionMatch.Value $driverMajor = $driverVersion.Split('.')[0] $edgeMajor = $edgeVersion.Split('.')[0] $isMatch = $driverMajor -eq $edgeMajor return [pscustomobject]@{ Match = $isMatch Reason = if ($isMatch) { $null } else { "Driver is version $driverVersion (major $driverMajor), installed Edge is $edgeVersion (major $edgeMajor)" } DriverVersion = $driverVersion EdgeVersion = $edgeVersion } } # --------------------------------------------------------------------------- function Test-NPSExtensionAutoDownloadCapability { <# .SYNOPSIS Checks whether headless auto-download (Invoke-NPSExtensionAutoDownload) has what it needs: the Selenium PowerShell module, and a webdriver executable (msedgedriver.exe or chromedriver.exe, the latter copy-renamed to stand in for the former - same trick NPSExtension_FullyWorkable_Downloader.ps1 uses) sitting in that module's assemblies folder AND actually version-matched to the installed Edge browser (see Test-NPSEdgeDriverVersionMatch - a driver merely existing isn't enough; a stale/mismatched one produces a cryptic Selenium "session not created" failure instead of a clear not-available result here). Informational/gating only - absence just means falling back to the manual download-page flow, never a hard failure. #> $seleniumModule = Get-Module -Name Selenium -ListAvailable | Select-Object -First 1 if (-not $seleniumModule) { return [pscustomobject]@{ Available = $false; Reason = "Selenium PowerShell module not installed"; ModuleBase = $null; DriverPath = $null } } $assemblyFolder = Join-Path $seleniumModule.ModuleBase "assemblies" $driverPath = @( Join-Path $assemblyFolder "msedgedriver.exe" Join-Path $assemblyFolder "chromedriver.exe" ) | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $driverPath) { return [pscustomobject]@{ Available = $false; Reason = "No msedgedriver.exe/chromedriver.exe found in $assemblyFolder"; ModuleBase = $seleniumModule.ModuleBase; DriverPath = $null } } $versionCheck = Test-NPSEdgeDriverVersionMatch -DriverPath $driverPath if (-not $versionCheck.Match) { return [pscustomobject]@{ Available = $false; Reason = "Driver found but not usable: $($versionCheck.Reason)"; ModuleBase = $seleniumModule.ModuleBase; DriverPath = $driverPath } } return [pscustomobject]@{ Available = $true; Reason = $null; ModuleBase = $seleniumModule.ModuleBase; DriverPath = $driverPath } } # --------------------------------------------------------------------------- function Install-NPSExtensionAutoDownloadPrereqs { <# .SYNOPSIS Installs whatever Test-NPSExtensionAutoDownloadCapability found missing: the Selenium PowerShell module (from PSGallery) and/or a matching msedgedriver.exe (from Microsoft's own versioned driver downloads). Best-effort throughout - every failure is collected and returned rather than thrown, since the caller's fallback (manual download-page flow) doesn't need either of these to exist. .DESCRIPTION Driver version matching: msedgedriver.exe versions are tied to a specific installed Edge build - downloading a mismatched one is a known way to get cryptic "session not created" failures at runtime, not a same-version-family free-for-all. This reads the ACTUAL installed Edge executable's file version (Program Files or Program Files (x86)) rather than assuming a specific location or trusting HKLM:\SOFTWARE\Microsoft\Edge\BLBeacon's "version" value - that registry value was confirmed EMPTY on a real test machine that otherwise has Edge installed (BLBeacon appears to only populate after Edge has actually run in a session, unreliable as a primary source) - so the exe's own VersionInfo.ProductVersion is used instead, confirmed reliable on the same machine. Download URL confirmed live against Microsoft's own WebDriver page, not guessed: https://msedgedriver.microsoft.com/{version}/edgedriver_win64.zip - NOTE this is a different host (msedgedriver.microsoft.com) than some third-party writeups suggest (msedgedriver.azureedge.net) - verified directly against the current page content. #> param() $result = [pscustomobject]@{ ModuleInstalled = $false DriverInstalled = $false ModuleBase = $null Errors = [System.Collections.Generic.List[string]]::new() } # --- Selenium PowerShell module --- $existingModule = Get-Module -Name Selenium -ListAvailable | Select-Object -First 1 if ($existingModule) { $result.ModuleInstalled = $true $result.ModuleBase = $existingModule.ModuleBase } else { try { Write-Host "Installing Selenium PowerShell module from PSGallery..." -ForegroundColor Cyan # NuGet provider is required by Install-Module and often isn't present yet on a fresh # server - bootstrap it first so this fails with a clear message here rather than a # confusing one deeper inside Install-Module. if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers -ErrorAction Stop | Out-Null } if ((Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue).InstallationPolicy -ne 'Trusted') { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } Install-Module -Name Selenium -Scope AllUsers -Force -AllowClobber -ErrorAction Stop $installedModule = Get-Module -Name Selenium -ListAvailable | Select-Object -First 1 $result.ModuleInstalled = [bool]$installedModule $result.ModuleBase = $installedModule.ModuleBase if ($result.ModuleInstalled) { Write-Host "Selenium module installed." -ForegroundColor Green } } catch { $result.Errors.Add("Selenium module install failed: $($_.Exception.Message)") } } # --- Matching msedgedriver.exe --- if ($result.ModuleBase) { $assemblyFolder = Join-Path $result.ModuleBase "assemblies" $existingDriver = @( Join-Path $assemblyFolder "msedgedriver.exe" Join-Path $assemblyFolder "chromedriver.exe" ) | Where-Object { Test-Path $_ } | Select-Object -First 1 # A driver merely EXISTING isn't enough - confirmed live (the maintainer) that a stale v79 driver # (the very first Chromium-Edge release) was already sitting in a real assemblies folder, # and got treated as "fine" here before this version check existed, producing Selenium's own # cryptic "session not created: Microsoft Edge version must be 79" failure at actual use time # instead of being caught and replaced here. $driverIsUsable = $false if ($existingDriver) { $versionCheck = Test-NPSEdgeDriverVersionMatch -DriverPath $existingDriver if ($versionCheck.Match) { $driverIsUsable = $true } else { Write-Host "Existing driver at $existingDriver is not usable: $($versionCheck.Reason) - replacing it." -ForegroundColor Yellow } } if ($driverIsUsable) { $result.DriverInstalled = $true } else { try { Write-Host "Downloading a matching msedgedriver.exe..." -ForegroundColor Cyan if (-not (Test-Path $assemblyFolder)) { New-Item -ItemType Directory -Path $assemblyFolder -Force | Out-Null } # A REPLACED driver's file lock can linger briefly after its process exits (e.g. the # --version check Test-NPSEdgeDriverVersionMatch just ran against it moments ago, or # AV/EDR scanning the just-executed file) - confirmed live (the maintainer) that the Copy-Item # below can otherwise fail with "being used by another process" even though nothing # is deliberately still running it. Same kill-then-wait pattern already used in # Invoke-NPSExtensionAutoDownload, applied here too before attempting to overwrite. Stop-Process -Name "msedgedriver" -Force -ErrorAction SilentlyContinue Stop-Process -Name "chromedriver" -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 1 $edgeVersion = Get-NPSInstalledEdgeVersion if (-not $edgeVersion) { throw "Microsoft Edge not found at either standard install path - can't determine which driver version to fetch." } Write-Host "Detected installed Edge version: $edgeVersion" -ForegroundColor Cyan [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $driverZipUrl = "https://msedgedriver.microsoft.com/$edgeVersion/edgedriver_win64.zip" $tempZip = Join-Path ([System.IO.Path]::GetTempPath()) ("edgedriver_" + [guid]::NewGuid().ToString('N') + ".zip") $tempExtract = Join-Path ([System.IO.Path]::GetTempPath()) ("edgedriver_" + [guid]::NewGuid().ToString('N')) try { Invoke-WebRequest -Uri $driverZipUrl -OutFile $tempZip -UseBasicParsing -ErrorAction Stop Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force $driverExe = Get-ChildItem -Path $tempExtract -Filter "msedgedriver.exe" -Recurse | Select-Object -First 1 if (-not $driverExe) { throw "Downloaded driver zip did not contain msedgedriver.exe." } # Retry the overwrite a few times - even after Stop-Process + a delay, a file # lock release can still be a hair slower than expected on a loaded/AV-scanned # system; this is cheap insurance against exactly the race just described. $destPath = Join-Path $assemblyFolder "msedgedriver.exe" $copySucceeded = $false $lastCopyError = $null for ($attempt = 1; $attempt -le 3; $attempt++) { try { Copy-Item -Path $driverExe.FullName -Destination $destPath -Force -ErrorAction Stop $copySucceeded = $true break } catch { $lastCopyError = $_ if ($attempt -lt 3) { Start-Sleep -Seconds 2 } } } if (-not $copySucceeded) { throw $lastCopyError } $result.DriverInstalled = $true Write-Host "Driver installed to $destPath" -ForegroundColor Green } finally { Remove-Item -Path $tempZip, $tempExtract -Recurse -Force -ErrorAction SilentlyContinue } } catch { $result.Errors.Add("Driver download failed: $($_.Exception.Message)") } } } return $result } # --------------------------------------------------------------------------- function Invoke-NPSExtensionAutoDownload { <# .SYNOPSIS Headless-browser download of NpsExtnForAzureMfaInstaller.exe with no tech intervention - adapted from the maintainer's Samples\NPSExtension_FullyWorkable_Downloader.ps1 (same technique, parameterized rather than hardcoded, plus the safety/error-handling changes noted below). Throws on any failure - callers should catch and fall back to Open-NPSExtensionDownloadPage's manual flow, never treat this as the only path. .DESCRIPTION Changes from the original script (kept deliberately minimal - proven-working automation, not something to rewrite from scratch): - -DestinationPath parameterized instead of a hardcoded "C:\Downloads" - avoids assuming a path exists/is writable on every NPS server, and lets Invoke-ManageNPSExtension control it. - TLS 1.3 is opportunistic, not assumed: older .NET Framework builds on older Windows Server releases don't define [Net.SecurityProtocolType]::Tls13 in the enum at all, which throws on the bare reference the original script uses unconditionally - wrapped in try/catch so this still runs correctly (TLS 1.2 only) on a server where that enum member doesn't exist. - Selenium/driver availability is checked by the caller first (Test-NPSExtensionAutoDownloadCapability) rather than failing deep inside a Selenium call with a less obvious error message. #> param([Parameter(Mandatory)][string]$DestinationPath) $capability = Test-NPSExtensionAutoDownloadCapability if (-not $capability.Available) { throw "Auto-download not available: $($capability.Reason)" } # "Available" only means the module MANIFEST is discoverable (Get-Module -ListAvailable) - the # [OpenQA.Selenium...] .NET types below aren't actually loaded into this session until it's # imported. Caught by testing this cold rather than assuming a caller already ran Import-Module. Import-Module Selenium -ErrorAction Stop $destDir = Split-Path -Path $DestinationPath -Parent if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } $assemblyFolder = Split-Path -Path $capability.DriverPath -Parent # chromedriver standing in for msedgedriver, same trick as the original script - Selenium's Edge # driver classes work against chromedriver's wire protocol since both are Chromium-based, as long # as a file literally named msedgedriver.exe exists in the assemblies folder. if ((Split-Path -Path $capability.DriverPath -Leaf) -eq 'chromedriver.exe' -and -not (Test-Path (Join-Path $assemblyFolder 'msedgedriver.exe'))) { Copy-Item -Path $capability.DriverPath -Destination (Join-Path $assemblyFolder 'msedgedriver.exe') -Force -ErrorAction SilentlyContinue } Stop-Process -Name "msedgedriver" -Force -ErrorAction SilentlyContinue Stop-Process -Name "chromedriver" -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 1 $driverInstance = $null try { $edgeOptions = [OpenQA.Selenium.Edge.EdgeOptions]::new() $edgeArgs = New-Object System.Collections.ArrayList [void]$edgeArgs.Add("--headless=old") [void]$edgeArgs.Add("--window-size=1920,1080") [void]$edgeArgs.Add("--disable-blink-features=AutomationControlled") [void]$edgeArgs.Add("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0") $edgeOptionsDict = [System.Collections.Generic.Dictionary[string, object]]::new() $edgeOptionsDict.Add("args", $edgeArgs) $edgeOptions.AddAdditionalCapability("ms:edgeOptions", $edgeOptionsDict) # Explicit driver filename (2-arg overload), NOT CreateDefaultService($assemblyFolder) alone - # confirmed live (the maintainer) that the single-arg overload can fail looking for # "MicrosoftWebDriver.exe" (the legacy pre-Chromium Edge driver name) even when # msedgedriver.exe is genuinely present in the folder - it apparently has its own internal # default-name logic that isn't reliably "msedgedriver.exe" across machines/Selenium builds. # By this point msedgedriver.exe is guaranteed to exist here (either natively, or copy- # renamed from chromedriver.exe above), so name it explicitly and remove the ambiguity. $driverService = [OpenQA.Selenium.Edge.EdgeDriverService]::CreateDefaultService($assemblyFolder, 'msedgedriver.exe') $driverService.HideCommandPromptWindow = $true $driverInstance = [OpenQA.Selenium.Edge.EdgeDriver]::new($driverService, $edgeOptions) $targetUrl = "https://www.microsoft.com/en-us/download/details.aspx?id=54688" Write-Host "Navigating to Microsoft Download Center..." -ForegroundColor Cyan $driverInstance.Navigate().GoToUrl($targetUrl) $driverInstance.Manage().Timeouts().ImplicitWait = [System.TimeSpan]::FromSeconds(15) Start-Sleep -Seconds 4 Write-Host "Opening download selections..." -ForegroundColor Cyan $downloadButton = $driverInstance.FindElement([OpenQA.Selenium.By]::CssSelector("button.dlcdetail__download-btn")) $downloadButton.Click() Start-Sleep -Seconds 3 Write-Host "Selecting NpsExtnForAzureMfaInstaller.exe..." -ForegroundColor Cyan $checkboxes = $driverInstance.FindElements([OpenQA.Selenium.By]::CssSelector("input[type='checkbox']")) foreach ($checkbox in $checkboxes) { $checkboxId = $checkbox.GetAttribute("id") if ($checkboxId -like "*NpsExtnForAzureMfaInstaller.exe*") { [void]$driverInstance.ExecuteScript("if(!arguments[0].checked) { arguments[0].click(); }", $checkbox) } else { [void]$driverInstance.ExecuteScript("if(arguments[0].checked) { arguments[0].click(); }", $checkbox) } } Start-Sleep -Seconds 2 Write-Host "Submitting to generate the download link..." -ForegroundColor Cyan $submitSelector = "(//button[text()='Download']) | //*[contains(@class, 'dlc-multi-file-submit')]" $submitBtn = $driverInstance.FindElement([OpenQA.Selenium.By]::XPath($submitSelector)) [void]$driverInstance.ExecuteScript("arguments[0].click();", $submitBtn) # Critical: Microsoft's JS needs a moment to compile and populate the page with the link token. Start-Sleep -Seconds 5 $pageSource = $driverInstance.PageSource if ($pageSource -notmatch 'https://download\.microsoft\.com/[^"\x27]*NpsExtnForAzureMfaInstaller\.exe[^"\x27]*') { throw "Could not locate the generated download URL in the page source - Microsoft's page layout may have changed." } $extractedUrl = $Matches[0] Write-Host "Found download URL." -ForegroundColor Green $driverInstance.Quit() $driverInstance.Dispose() $driverInstance = $null # Opportunistic TLS 1.3 - see .DESCRIPTION on why this can't be assumed to exist in the enum. try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } Write-Host "Downloading to $DestinationPath ..." -ForegroundColor Cyan Invoke-WebRequest -Uri $extractedUrl -OutFile $DestinationPath -UseBasicParsing -UserAgent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" if (-not (Test-Path $DestinationPath)) { throw "Download reported success but $DestinationPath doesn't exist." } return [pscustomobject]@{ Success = $true; DestinationPath = $DestinationPath; SourceUrl = $extractedUrl } } finally { if ($null -ne $driverInstance) { try { $driverInstance.Quit(); $driverInstance.Dispose() } catch {} } } } # --------------------------------------------------------------------------- function Open-NPSExtensionDownloadPage { <# .SYNOPSIS Opens the Microsoft Download Center landing page in the default browser - there is no stable direct-download URL for the installer itself (verified live earlier this session), so this can't be more automated than "open the page and tell the tech what to click." #> $url = 'https://www.microsoft.com/en-us/download/details.aspx?id=54688' Write-Host "Opening $url ..." -ForegroundColor Cyan Write-Host "On that page: click Download, then select 'NpsExtnForAzureMfaInstaller.exe'." -ForegroundColor Cyan try { Start-Process $url } catch { Write-Host "Could not open a browser automatically - navigate to the URL above manually." -ForegroundColor Yellow } } |