diff --git a/Remove-UserAuthenticationMethod.ps1 b/Remove-UserAuthenticationMethod.ps1 new file mode 100644 index 0000000..ba3dc6f --- /dev/null +++ b/Remove-UserAuthenticationMethod.ps1 @@ -0,0 +1,541 @@ +<# +.SYNOPSIS + Removes a chosen type of registered authentication method (phone, Microsoft + Authenticator, FIDO2, etc.) from Entra ID users in bulk. + +.DESCRIPTION + Connects to Microsoft Graph, lets the caller pick which authentication method + type to remove (interactively from a menu, or via -MethodType for unattended + runs), then removes every registered entry of that type for each in-scope user. + + Safety: for MFA-capable method types, if the selected type is a user's only + remaining MFA-capable method, removing it would leave them unable to complete MFA + at all. This script never does that - such users are automatically skipped (never + modified) and reported separately so an admin can register them another method + first. Temporary Access Pass does not count as a substitute method for this check, + since it is a one-time/short-lived bootstrap credential, not an ongoing MFA + method. Email is not an MFA method at all (SSPR only), so removing it is never + blocked by this check. + +.PARAMETER TenantId + Optional tenant ID or verified domain name to sign in to. If omitted, + Connect-MgGraph uses the interactive account picker / default tenant. + +.PARAMETER MethodType + Which authentication method type to remove: Phone, MicrosoftAuthenticator, + Fido2, WindowsHelloForBusiness, SoftwareOath, TemporaryAccessPass, + PlatformCredential, or Email. Omit to choose from an interactive menu instead. + +.PARAMETER UserPrincipalName + Optional. Limit the run to these specific users instead of the whole tenant. + +.PARAMETER IncludeGuests + Include guest (B2B) accounts. By default only enabled Member users are processed. + +.PARAMETER OutputFolder + Folder to write the results CSV to. Defaults to the current working directory. + +.EXAMPLE + .\Remove-UserAuthenticationMethod.ps1 -WhatIf + Signs in, prompts for which method type to remove, lists every enabled Member + user in the tenant, and shows what would be removed (and what would be skipped + to avoid leaving a user with no MFA method) without changing anything. + +.EXAMPLE + .\Remove-UserAuthenticationMethod.ps1 -MethodType Phone -TenantId 'contoso.onmicrosoft.com' + Signs in to the specified tenant and, after a single confirmation prompt, removes + every eligible user's phone authentication method(s). + +.EXAMPLE + .\Remove-UserAuthenticationMethod.ps1 -MethodType Phone -UserPrincipalName 'alice@contoso.com','bob@contoso.com' -Confirm:$false + Removes phone authentication methods for just the two named users, without even + the single upfront confirmation prompt - use only once a prior -WhatIf run against + the same scope has already been reviewed. + +.NOTES + Author: Petr Stepan + Created: 2026-07-29 + Version: 1.1.0 + Changelog: + 1.0.0 - Initial version (phone/SMS-Voice authentication methods only) + 1.1.0 - Generalized to any removable authentication method type, selected + interactively from a menu or via the new -MethodType parameter +#> + +#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Users + +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] +param( + [Parameter(Mandatory = $false, HelpMessage = 'Tenant ID or verified domain name to connect to.')] + [ValidateNotNullOrEmpty()] + [string] + $TenantId, + + [Parameter(Mandatory = $false, HelpMessage = 'Authentication method type to remove. Omit to choose interactively from a menu.')] + [ValidateSet('Phone', 'MicrosoftAuthenticator', 'Fido2', 'WindowsHelloForBusiness', 'SoftwareOath', 'TemporaryAccessPass', 'PlatformCredential', 'Email')] + [string] + $MethodType, + + [Parameter(Mandatory = $false, HelpMessage = 'Limit the run to these specific users instead of the whole tenant.')] + [string[]] + $UserPrincipalName, + + [Parameter(Mandatory = $false)] + [switch] + $IncludeGuests, + + [Parameter(Mandatory = $false, HelpMessage = 'Folder to write the results CSV to.')] + [ValidateNotNullOrEmpty()] + [string] + $OutputFolder = (Get-Location).Path +) + +# --- Safety preamble ------------------------------------------------------- +# Fail fast on uninitialized variables and typos in property/member names. +Set-StrictMode -Version Latest +# Treat all non-terminating errors as terminating so failures can't slip by silently. +$ErrorActionPreference = 'Stop' + +# Graph scopes needed: read users, and read/remove their authentication methods. +$script:RequiredGraphScopes = @( + 'User.Read.All' + 'UserAuthenticationMethod.ReadWrite.All' +) + +# Every authentication method type this script knows how to remove, and how to +# remove it: the Graph @odata.type value(s) that identify an entry of this type, +# the dedicated Remove-Mg cmdlet for it (each method type has its own - there is no +# single generic "remove any method" cmdlet), and that cmdlet's ID parameter name. +# IsMfaCapable marks whether an entry of this type counts towards the "does this +# user have another MFA method left" safety check below - Temporary Access Pass +# (one-time/short-lived bootstrap credential) and Email (SSPR only, not MFA) do not. +$script:MethodTypeCatalog = @( + [PSCustomObject]@{ + Name = 'Phone' + DisplayName = 'Phone (SMS / Voice call)' + OdataTypes = @('#microsoft.graph.phoneAuthenticationMethod') + RemoveCmdlet = 'Remove-MgUserAuthenticationPhoneMethod' + IdParameter = 'PhoneAuthenticationMethodId' + IsMfaCapable = $true + } + [PSCustomObject]@{ + Name = 'MicrosoftAuthenticator' + DisplayName = 'Microsoft Authenticator (push / passwordless)' + # Both the classic push method and the passwordless/passkey-in-Authenticator + # variant are registrations of the same underlying device and are removed + # through the same cmdlet - there is no separate "passwordless" delete API. + OdataTypes = @( + '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' + '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod' + ) + RemoveCmdlet = 'Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod' + IdParameter = 'MicrosoftAuthenticatorAuthenticationMethodId' + IsMfaCapable = $true + } + [PSCustomObject]@{ + Name = 'Fido2' + DisplayName = 'FIDO2 / Passkey' + OdataTypes = @('#microsoft.graph.fido2AuthenticationMethod') + RemoveCmdlet = 'Remove-MgUserAuthenticationFido2Method' + IdParameter = 'Fido2AuthenticationMethodId' + IsMfaCapable = $true + } + [PSCustomObject]@{ + Name = 'WindowsHelloForBusiness' + DisplayName = 'Windows Hello for Business' + OdataTypes = @('#microsoft.graph.windowsHelloForBusinessAuthenticationMethod') + RemoveCmdlet = 'Remove-MgUserAuthenticationWindowsHelloForBusinessMethod' + IdParameter = 'WindowsHelloForBusinessAuthenticationMethodId' + IsMfaCapable = $true + } + [PSCustomObject]@{ + Name = 'SoftwareOath' + DisplayName = 'OATH software token' + OdataTypes = @('#microsoft.graph.softwareOathAuthenticationMethod') + RemoveCmdlet = 'Remove-MgUserAuthenticationSoftwareOathMethod' + IdParameter = 'SoftwareOathAuthenticationMethodId' + IsMfaCapable = $true + } + [PSCustomObject]@{ + Name = 'TemporaryAccessPass' + DisplayName = 'Temporary Access Pass' + OdataTypes = @('#microsoft.graph.temporaryAccessPassAuthenticationMethod') + RemoveCmdlet = 'Remove-MgUserAuthenticationTemporaryAccessPassMethod' + IdParameter = 'TemporaryAccessPassAuthenticationMethodId' + IsMfaCapable = $false + } + [PSCustomObject]@{ + Name = 'PlatformCredential' + DisplayName = 'Platform credential (e.g. Mac)' + OdataTypes = @('#microsoft.graph.platformCredentialAuthenticationMethod') + RemoveCmdlet = 'Remove-MgUserAuthenticationPlatformCredentialMethod' + IdParameter = 'PlatformCredentialAuthenticationMethodId' + IsMfaCapable = $true + } + [PSCustomObject]@{ + Name = 'Email' + DisplayName = 'Email (SSPR only)' + OdataTypes = @('#microsoft.graph.emailAuthenticationMethod') + RemoveCmdlet = 'Remove-MgUserAuthenticationEmailMethod' + IdParameter = 'EmailAuthenticationMethodId' + IsMfaCapable = $false + } +) + +# Flat list of every @odata.type that counts as an ongoing MFA-capable method, +# derived from the catalog above so the two can never drift out of sync. +$script:MfaCapableOdataTypes = @($script:MethodTypeCatalog | Where-Object { $_.IsMfaCapable } | ForEach-Object { $_.OdataTypes }) + +# --- Functions --------------------------------------------------------- + +function Connect-TenantGraph { + <# + .SYNOPSIS + Ensures an authenticated Microsoft Graph session with the required scopes. + .DESCRIPTION + Read-only from the script's perspective (it only inspects/creates an auth + session), so it does not need ShouldProcess. Reuses an existing connection if + it already grants every required scope, to avoid re-prompting unnecessarily. + .PARAMETER TenantId + Optional tenant ID or verified domain name to connect to. + .EXAMPLE + Connect-TenantGraph -TenantId 'contoso.onmicrosoft.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string] + $TenantId + ) + + $context = Get-MgContext + $hasAllScopes = $context -and (-not (Compare-Object -ReferenceObject $script:RequiredGraphScopes -DifferenceObject $context.Scopes | Where-Object { $_.SideIndicator -eq '<=' })) + + if (-not $hasAllScopes) { + $connectParams = @{ + Scopes = $script:RequiredGraphScopes + NoWelcome = $true + } + if ($TenantId) { + $connectParams['TenantId'] = $TenantId + } + Connect-MgGraph @connectParams + } + else { + Write-Verbose 'Reusing existing Microsoft Graph connection with sufficient scopes.' + } +} + +function Select-AuthenticationMethodType { + <# + .SYNOPSIS + Resolves which authentication method type this run should remove. + .DESCRIPTION + Interactive/read-only - does not modify anything, so it does not need + ShouldProcess. Returns the matching catalog entry for -MethodType if one was + given; otherwise prints a numbered menu and prompts until a valid choice is + made. + .PARAMETER MethodType + Optional method type name already chosen by the caller (skips the prompt). + .EXAMPLE + Select-AuthenticationMethodType -MethodType 'Phone' + .EXAMPLE + Select-AuthenticationMethodType + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string] + $MethodType + ) + + if ($MethodType) { + $selected = $script:MethodTypeCatalog | Where-Object { $_.Name -eq $MethodType } + if (-not $selected) { + throw "Unknown method type '$MethodType'." + } + return $selected + } + + Write-Host "`nWhich authentication method type should be removed?" -ForegroundColor Cyan + for ($index = 0; $index -lt $script:MethodTypeCatalog.Count; $index++) { + Write-Host (" {0}. {1}" -f ($index + 1), $script:MethodTypeCatalog[$index].DisplayName) + } + + $chosenIndex = -1 + do { + $choice = Read-Host "`nEnter a number (1-$($script:MethodTypeCatalog.Count))" + $parsedNumber = 0 + $isValidNumber = [int]::TryParse($choice, [ref]$parsedNumber) + if ($isValidNumber -and $parsedNumber -ge 1 -and $parsedNumber -le $script:MethodTypeCatalog.Count) { + $chosenIndex = $parsedNumber - 1 + } + else { + Write-Warning 'Invalid choice - please enter a number from the list.' + } + } while ($chosenIndex -lt 0) + + return $script:MethodTypeCatalog[$chosenIndex] +} + +function Get-TenantUserInventory { + <# + .SYNOPSIS + Returns the users this run should process. + .DESCRIPTION + Read-only; does not modify anything, so it does not need ShouldProcess. Returns + either the specific users named via -UserPrincipalName, or every enabled Member + user in the tenant (guests included only when -IncludeGuests is set). + .PARAMETER UserPrincipalName + Optional. Specific users to return instead of the whole tenant. + .PARAMETER IncludeGuests + Include guest (B2B) accounts when returning the whole tenant. + .EXAMPLE + Get-TenantUserInventory + .EXAMPLE + Get-TenantUserInventory -UserPrincipalName 'alice@contoso.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string[]] + $UserPrincipalName, + + [Parameter(Mandatory = $false)] + [switch] + $IncludeGuests + ) + + if ($UserPrincipalName) { + $users = [System.Collections.Generic.List[object]]::new() + foreach ($upn in $UserPrincipalName) { + try { + $users.Add((Get-MgUser -UserId $upn -Property Id, DisplayName, UserPrincipalName -ErrorAction Stop)) + } + catch { + Write-Warning "Could not find user '$upn': $_" + } + } + return @($users) + } + + $userFilter = "accountEnabled eq true" + if (-not $IncludeGuests) { + $userFilter += " and userType eq 'Member'" + } + try { + return @(Get-MgUser -Filter $userFilter -ConsistencyLevel eventual -CountVariable usersFound ` + -Property Id, DisplayName, UserPrincipalName -All -PageSize 500 -Sort DisplayName -ErrorAction Stop) + } + catch { + Write-Error "Failed to retrieve users from Entra ID: $_" + throw + } +} + +function Remove-UserAuthenticationMethodEntry { + <# + .SYNOPSIS + Removes every registered authentication method of the selected type for one + user, unless doing so would leave them with no MFA-capable method at all. + .DESCRIPTION + Each matching entry is removed independently, gated behind ShouldProcess, so + -WhatIf reports exactly what would be removed and a failure removing one entry + does not prevent the others from being attempted. + .PARAMETER UserId + The Entra ID object ID of the user. + .PARAMETER UserPrincipalName + Used only for readable ShouldProcess prompts/log messages. + .PARAMETER MethodTypeInfo + The catalog entry (an item from $script:MethodTypeCatalog) describing which + method type to remove and which Graph cmdlet/parameter to use. + .EXAMPLE + Remove-UserAuthenticationMethodEntry -UserId $id -UserPrincipalName 'alice@contoso.com' -MethodTypeInfo $methodTypeInfo + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $UserId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $UserPrincipalName, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [PSCustomObject] + $MethodTypeInfo + ) + + [array]$allMethods = @(Get-MgUserAuthenticationMethod -UserId $UserId -ErrorAction Stop) + [array]$matchingMethods = @($allMethods | Where-Object { $_.AdditionalProperties['@odata.type'] -in $MethodTypeInfo.OdataTypes }) + + if ($matchingMethods.Count -eq 0) { + return [PSCustomObject]@{ + Status = 'NoMatchingMethod' + RemovedCount = 0 + Errors = [System.Collections.Generic.List[string]]::new() + } + } + + if ($MethodTypeInfo.IsMfaCapable) { + [array]$remainingMfaMethods = @($allMethods | Where-Object { + $_.AdditionalProperties['@odata.type'] -in $script:MfaCapableOdataTypes -and + $_.AdditionalProperties['@odata.type'] -notin $MethodTypeInfo.OdataTypes + }) + if ($remainingMfaMethods.Count -eq 0) { + # Removing this would leave the user with zero MFA-capable methods - + # never do that; flag for manual remediation instead. + return [PSCustomObject]@{ + Status = 'Skipped' + RemovedCount = 0 + Errors = [System.Collections.Generic.List[string]]::new() + } + } + } + + $errors = [System.Collections.Generic.List[string]]::new() + $removedCount = 0 + + foreach ($method in $matchingMethods) { + $target = "$UserPrincipalName ($($MethodTypeInfo.DisplayName))" + if ($PSCmdlet.ShouldProcess($target, "Remove $($MethodTypeInfo.DisplayName) authentication method")) { + try { + $removeParams = @{ + UserId = $UserId + ErrorAction = 'Stop' + } + $removeParams[$MethodTypeInfo.IdParameter] = $method.Id + & $MethodTypeInfo.RemoveCmdlet @removeParams + $removedCount++ + } + catch { + $errors.Add("Failed to remove entry: $_") + Write-Warning "Failed to remove $($MethodTypeInfo.DisplayName) method for '$UserPrincipalName': $_" + } + } + } + + $status = if ($errors.Count -gt 0) { 'Failed' } else { 'Removed' } + return [PSCustomObject]@{ + Status = $status + RemovedCount = $removedCount + Errors = $errors + } +} + +# --- Main -------------------------------------------------------------- +$results = [System.Collections.Generic.List[PSCustomObject]]::new() +$hasFailures = $false + +try { + Write-Verbose 'Connecting to Microsoft Graph.' + Connect-TenantGraph -TenantId $TenantId + + $methodTypeInfo = Select-AuthenticationMethodType -MethodType $MethodType + Write-Host "Selected method type: $($methodTypeInfo.DisplayName)" -ForegroundColor Cyan + + # One upfront confirmation for the whole run, instead of a separate interactive + # prompt per user. Skipped entirely under -WhatIf (nothing to confirm for a + # preview) and honored if the caller explicitly passed -Confirm:$false. Every + # individual removal below then runs with -Confirm:$false, since the decision to + # proceed has already been made here. + if (-not $WhatIfPreference) { + $confirmExplicitlySuppressed = $PSBoundParameters.ContainsKey('Confirm') -and -not $PSBoundParameters['Confirm'] + $mfaWarning = if ($methodTypeInfo.IsMfaCapable) { 'Users for whom this is their only remaining MFA-capable method are skipped automatically. ' } else { '' } + if (-not $confirmExplicitlySuppressed -and -not $PSCmdlet.ShouldContinue( + "This will permanently remove '$($methodTypeInfo.DisplayName)' authentication methods from every eligible user in scope. $mfaWarning" + 'This cannot be undone by this script - continue?', + 'Confirm authentication method removal')) { + Write-Warning 'Aborted by user - no authentication methods were removed.' + exit 0 + } + } + + Write-Verbose 'Retrieving user accounts.' + $users = @(Get-TenantUserInventory -UserPrincipalName $UserPrincipalName -IncludeGuests:$IncludeGuests) + Write-Host "Found $($users.Count) user account(s) to process." -ForegroundColor Cyan + + [int]$i = 0 + foreach ($user in $users) { + $i++ + Write-Progress -Activity "Removing $($methodTypeInfo.DisplayName) authentication methods" -Status "$($user.DisplayName) ($i of $($users.Count))" -PercentComplete (($i / $users.Count) * 100) + + $resultRow = [PSCustomObject]@{ + DisplayName = $user.DisplayName + UserPrincipalName = $user.UserPrincipalName + Status = 'Unknown' + RemovedCount = 0 + ErrorDetail = $null + } + + try { + # Forward -WhatIf explicitly rather than relying on $WhatIfPreference to + # cascade implicitly - keeps the dry-run behavior obvious and independent + # of scope-inheritance quirks. -Confirm:$false because the single upfront + # confirmation above already covers this whole run. + $removalResult = Remove-UserAuthenticationMethodEntry -UserId $user.Id -UserPrincipalName $user.UserPrincipalName ` + -MethodTypeInfo $methodTypeInfo -WhatIf:$WhatIfPreference -Confirm:$false + $resultRow.Status = $removalResult.Status + $resultRow.RemovedCount = $removalResult.RemovedCount + if ($removalResult.Errors.Count -gt 0) { + $resultRow.ErrorDetail = $removalResult.Errors -join '; ' + } + } + catch { + # Do not let one user's failure abort the whole tenant run. + $resultRow.Status = 'Failed' + $resultRow.ErrorDetail = "$_" + Write-Warning "Skipping '$($user.UserPrincipalName)' after failure: $_" + } + + if ($resultRow.Status -eq 'Failed') { + $hasFailures = $true + } + + $results.Add($resultRow) + } + Write-Progress -Activity "Removing $($methodTypeInfo.DisplayName) authentication methods" -Completed + + if (-not (Test-Path -Path $OutputFolder)) { + New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null + } + $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' + $csvPath = Join-Path $OutputFolder "RemoveUserAuthMethod_$($methodTypeInfo.Name)_$timestamp.csv" + + # Only surface users the script acted on (or would act on) or flagged for manual + # review - users with no matching method registered at all are deliberately + # omitted to keep the report focused on what actually needs attention. + $reportableResults = @($results | Where-Object { $_.Status -ne 'NoMatchingMethod' }) + $reportableResults | Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8 + + $removedUserCount = @($results | Where-Object { $_.Status -eq 'Removed' }).Count + $skippedUserCount = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count + $failedUserCount = @($results | Where-Object { $_.Status -eq 'Failed' }).Count + $totalRemovedMethods = ($results | Measure-Object -Property RemovedCount -Sum).Sum + if (-not $totalRemovedMethods) { $totalRemovedMethods = 0 } + + Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta + Write-Host "Method type: $($methodTypeInfo.DisplayName)" + Write-Host "Users processed: $($results.Count)" + Write-Host "Users with method(s) removed: $removedUserCount" -ForegroundColor Green + Write-Host "Methods removed in total: $totalRemovedMethods" -ForegroundColor Green + Write-Host "Users skipped (only remaining MFA method): $skippedUserCount" -ForegroundColor Yellow + Write-Host "Users failed: $failedUserCount" -ForegroundColor Red + Write-Host "Results CSV: $csvPath" +} +catch { + Write-Error "Script failed: $_" + exit 1 +} +finally { + # No persistent sessions to release - Connect-MgGraph tokens are cached by the SDK. +} + +if ($hasFailures) { + exit 1 +} + +exit 0