Public/Entra/LAPS/Get-MgLAPSPassword.ps1

<#
    .SYNOPSIS
    Retrieves the LAPS password for a Microsoft Entra ID device.
 
    .DESCRIPTION
    Gets the Windows Local Administrator Password Solution (LAPS) password for one or all devices in Microsoft Entra ID (formerly Azure AD).
    By default, only metadata is returned (no password). Use -ShowPassword to retrieve the password in plain text.
    Passwords can optionally be backed up to an Azure Key Vault.
 
    .PARAMETER DeviceName
    Filter results to a specific device by its display name.
    Cannot be used together with DeviceID parameter.
 
    .PARAMETER DeviceID
    Filter results to a specific device by its Entra ID (Azure AD) object ID.
    If not specified, retrieves LAPS passwords for all devices.
    Cannot be used together with DeviceName parameter.
 
    .PARAMETER ShowPassword
    Retrieve and display the LAPS password in plain text.
    By default, only metadata (expiration time, etc.) is returned.
    Use with caution, as this will expose the password in the console output.
 
    .PARAMETER IncludeHistory
    Include previous LAPS passwords in the output, in addition to the current one.
    Only applicable when -ShowPassword or -BackupToKeyVault is specified. Has no effect otherwise.
    The output includes an IsCurrent property to identify the active password.
 
    .PARAMETER RunFromAzureAutomation
    Use managed identity authentication instead of interactive authentication.
    Suitable for Azure Automation runbooks, Azure Functions, or VMs with managed identity enabled.
 
    .PARAMETER BackupToKeyVault
    Enable backup of LAPS passwords to Azure Key Vault.
    Must be used together with -KeyVaultName.
    The secret name is the device name; the Content Type field contains the account name and backup date.
 
    .PARAMETER KeyVaultName
    Name of the Azure Key Vault to back up LAPS passwords to.
    Mandatory when -BackupToKeyVault is specified.
    Requires the Az.KeyVault module and appropriate permissions.
 
    .EXAMPLE
    Get-MgLAPSPassword
 
    Retrieves metadata (no password) for all devices with LAPS configured.
 
    .EXAMPLE
    Get-MgLAPSPassword -DeviceName "DESKTOP-ABC123"
 
    Retrieves metadata (no password) for the device with the specified display name.
 
    .EXAMPLE
    Get-MgLAPSPassword -DeviceID "12345678-1234-1234-1234-123456789012"
 
    Retrieves metadata (no password) for the specified device.
 
    .EXAMPLE
    Get-MgLAPSPassword -ShowPassword
 
    Retrieves the current LAPS password in plain text for all devices.
 
    .EXAMPLE
    Get-MgLAPSPassword -DeviceID "12345678-1234-1234-1234-123456789012" -ShowPassword
 
    Retrieves the current LAPS password in plain text for the specified device.
 
    .EXAMPLE
    Get-MgLAPSPassword -DeviceID "12345678-1234-1234-1234-123456789012" -ShowPassword -IncludeHistory
 
    Retrieves the current and historical LAPS passwords for the specified device.
    The IsCurrent property indicates which entry is the active password.
 
    .EXAMPLE
    Get-MgLAPSPassword -BackupToKeyVault -KeyVaultName "MyLAPSVault"
 
    Backs up LAPS passwords for all devices to Azure Key Vault.
 
    .EXAMPLE
    Get-MgLAPSPassword -DeviceID "12345678-1234-1234-1234-123456789012" -BackupToKeyVault -KeyVaultName "MyLAPSVault"
 
    Backs up the LAPS password for the specified device to Azure Key Vault.
 
    .EXAMPLE
    Get-MgLAPSPassword -RunFromAzureAutomation -BackupToKeyVault -KeyVaultName "MyLAPSVault"
 
    Backs up LAPS passwords for all devices using managed identity authentication. Suitable for Azure Automation runbooks.
 
    .LINK
    https://ps365.clidsys.com/docs/commands/Get-MgLAPSPassword
 
    .NOTES
    Requires the DeviceLocalCredential.Read.All and Device.Read.All permissions in Microsoft Entra ID.
#>


function Get-MgLAPSPassword {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(HelpMessage = 'Filter by device display name (cannot be used with DeviceID)')]
        [ValidateNotNullOrEmpty()]
        [string]$DeviceName,

        [Parameter(HelpMessage = 'Filter by Entra ID (Azure AD) device object ID (cannot be used with DeviceName). If not specified, retrieves LAPS passwords for all devices.')]
        [ValidateNotNullOrEmpty()]
        [string]$DeviceID,

        [switch]$ShowPassword,
        [switch]$IncludeHistory,

        [Parameter(HelpMessage = 'Use managed identity authentication (for Azure Automation)')]
        [switch]$RunFromAzureAutomation,

        [Parameter(Mandatory, ParameterSetName = 'KeyVault', HelpMessage = 'Enable backup of LAPS passwords to Azure Key Vault')]
        [switch]$BackupToKeyVault,

        [Parameter(Mandatory, ParameterSetName = 'KeyVault', HelpMessage = 'Azure Key Vault name to backup LAPS passwords')]
        [ValidateNotNullOrEmpty()]
        [string]$KeyVaultName
    )

    # Validate that only one device filter is specified
    if ($PSBoundParameters.ContainsKey('DeviceName') -and $PSBoundParameters.ContainsKey('DeviceID')) {
        Write-Error 'Cannot specify both DeviceName and DeviceID parameters. Please use only one.' -ErrorAction Stop
    }

    $fetchPasswords = $ShowPassword.IsPresent -or $BackupToKeyVault.IsPresent

    if ($IncludeHistory.IsPresent -and -not $fetchPasswords) {
        Write-Warning '-IncludeHistory has no effect without -ShowPassword or -BackupToKeyVault.'
    }

    $requiredScopes = @('DeviceLocalCredential.Read.All', 'Device.Read.All')

    # Version check for Azure Automation before connecting
    if ($RunFromAzureAutomation.IsPresent) {
        if ($PSVersionTable.PSVersion -lt [version]'7.4.0') {
            $mgAuth = Get-Module 'Microsoft.Graph.Authentication' -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1
            if ($mgAuth -and [version]$mgAuth.Version -gt [version]'2.25.0') {
                Write-Error "Microsoft.Graph.Authentication v$($mgAuth.Version) is not compatible with Azure Automation on PowerShell $($PSVersionTable.PSVersion). Maximum supported version is 2.25.0. Script execution stopped." -ErrorAction Stop
                return
            }
        }
    }

    $isConnected = $null -ne (Get-MgContext -ErrorAction SilentlyContinue)

    if (-not $isConnected) {
        if ($RunFromAzureAutomation.IsPresent) {
            Write-Verbose 'Connecting to Microsoft Graph using Managed Identity'
            Connect-MgGraph -Identity -NoWelcome
        }
        else {
            Write-Verbose "Connecting to Microsoft Graph. Scopes: $($requiredScopes -join ',')"
            $null = Connect-MgGraph -Scopes $requiredScopes -NoWelcome
        }
    }

    # Setup Azure Key Vault connection if backup is requested
    if ($BackupToKeyVault.IsPresent) {
        Write-Verbose 'Setting up Azure Key Vault connection for LAPS password backup...'
        $keyVaultName = $KeyVaultName
        Write-Verbose "Using Key Vault: $keyVaultName"

        try {
            if (-not (Get-Module -ListAvailable -Name Az.KeyVault)) {
                Write-Error 'Az.KeyVault module is required for Key Vault backup. Install it with: Install-Module Az.KeyVault' -ErrorAction Stop
            }

            Write-Verbose 'Connecting to Azure for Key Vault access...'
            try {
                $azContext = Get-AzContext -ErrorAction SilentlyContinue
                if (-not $azContext) {
                    $null = Connect-AzAccount -Identity -ErrorAction Stop
                    Write-Verbose 'Connected to Azure using Managed Identity'
                }
                else {
                    Write-Verbose 'Using existing Azure connection'
                }
            }
            catch {
                Write-Warning 'Failed to connect to Azure automatically. Please ensure you are logged in with Connect-AzAccount or using Managed Identity.'
                Write-Error "Azure connection required for Key Vault backup: $($_.Exception.Message)" -ErrorAction Stop
            }

            Write-Verbose "Verifying access to Key Vault: $keyVaultName"
            try {
                $keyVault = Get-AzKeyVault -VaultName $keyVaultName -ErrorAction Stop
                Write-Verbose "Successfully verified access to Key Vault: $($keyVault.VaultName)"
            }
            catch {
                Write-Error "Cannot access Key Vault '$keyVaultName'. Please ensure it exists and you have appropriate permissions: $($_.Exception.Message)" -ErrorAction Stop
            }
        }
        catch {
            Write-Error "Failed to setup Azure Key Vault connection: $($_.Exception.Message)" -ErrorAction Stop
        }
    }

    #Define the URI path
    # Build request headers (used for both list and individual calls)
    $headers = @{
        'ocp-client-name'    = 'Get-LapsAADPassword Windows LAPS Cmdlet'
        'ocp-client-version' = '1.0'
    }

    # Build the list of device credential IDs to process
    $deviceCredentialIds = [System.Collections.Generic.List[string]]@()

    if ($PSBoundParameters.ContainsKey('DeviceName')) {
        Write-Verbose "Resolving device name '$DeviceName' to Entra ID object ID..."
        $mgDevice = Get-MgDevice -Filter "displayName eq '$DeviceName'" -ErrorAction Stop | Select-Object -First 1
        if (-not $mgDevice) {
            Write-Warning "No device found with display name '$DeviceName'"
            return
        }
        Write-Verbose "Resolved '$DeviceName' to device object ID: $($mgDevice.DeviceId)"
        $deviceCredentialIds.Add($mgDevice.DeviceId)
    }
    elseif ($PSBoundParameters.ContainsKey('DeviceID')) {
        $deviceCredentialIds.Add($DeviceID)
    }
    else {
        Write-Verbose 'No device filter specified - retrieving all device LAPS credentials...'
        $listUri = 'v1.0/directory/deviceLocalCredentials'
        $listResponse = Invoke-MgGraphRequest -Method GET -Uri $listUri -Headers $headers -OutputType PSObject
        foreach ($item in $listResponse.value) {
            $deviceCredentialIds.Add($item.id)
        }
        Write-Verbose "Found $($deviceCredentialIds.Count) devices with LAPS credentials"
    }

    # Counter for Key Vault secret creations - Key Vault allows max 300 creations per 10 seconds
    if ($PSBoundParameters['KeyVaultName']) { $kvSecretCreationCount = 0 }

    foreach ($deviceCredentialId in $deviceCredentialIds) {

        # New correlation ID per request
        $headers['client-request-id'] = [System.Guid]::NewGuid().ToString()

        $uri = 'v1.0/directory/deviceLocalCredentials/' + $deviceCredentialId
        # ?$select=credentials will cause the server to return all credentials, ie latest plus history

        if ($fetchPasswords) {
            $uri = $uri + '?$select=credentials'
        }

        #Initation the request to Microsoft Graph for the LAPS password
        try {
            $response = Invoke-MgGraphRequest -Method GET -Uri $URI -Headers $headers -OutputType Json
        }
        catch {
            Write-Warning "Device ID: $deviceCredentialId $($_.Exception.Message -replace "`n", ' ' -replace "`r", ' ')"
            $object = [PSCustomObject][ordered]@{
                DeviceName             = '$null'
                DeviceId               = $deviceCredentialId
                PasswordExpirationTime = $null
            }

            $object
            continue
        }

        if ([string]::IsNullOrWhitespace($response)) {
            $object = [PSCustomObject][ordered]@{
                DeviceName             = '$null'
                DeviceId               = $deviceCredentialId
                PasswordExpirationTime = $null
            }

            $object
            continue
        }

        # Build custom PS output object
        $resultsJson = ConvertFrom-Json $response
    
        $lapsDeviceId = $resultsJson.deviceName

        $lapsDeviceId = New-Object([System.Guid])
        $lapsDeviceId = [System.Guid]::Parse($resultsJson.id)

        # Grab password expiration time (only applies to the latest password)
        $lapsPasswordExpirationTime = Get-Date $resultsJson.refreshDateTime

        if ($fetchPasswords) {
            # Copy the credentials array
            $credentials = $resultsJson.credentials

            # Sort the credentials array by backupDateTime.
            $credentials = $credentials | Sort-Object -Property backupDateTime -Descending

            # Note: current password (ie, the one most recently set) is now in the zero position of the array

            # If history was not requested, truncate the credential array down to just the latest one
            if (-not $IncludeHistory) {
                $credentials = @($credentials[0])
            }

            $currentCredential = $credentials[0]

            # When backing up history to Key Vault, process oldest first so the most recent
            # password is written last and becomes the active version of the secret
            $credentialsToProcess = if ($BackupToKeyVault.IsPresent -and $IncludeHistory) {
                @($credentials | Sort-Object -Property backupDateTime)
            }
            else {
                $credentials
            }

            foreach ($credential in $credentialsToProcess) {

                # Cloud returns passwords in base64, decode to plain text
                $plainText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($credential.passwordBase64))

                # Backup to Key Vault if requested
                if ($BackupToKeyVault.IsPresent) {
                    try {
                        $backupDate = if ($credential.backupDateTime) { (Get-Date $credential.backupDateTime -Format 'yyyy-MM-dd-HHmmss') } else { 'unknown' }
                        $secretName = "LAPS-$($resultsJson.deviceName)" -replace '[^0-9a-zA-Z-]', '-'
                        $contentType = "$backupDate-$($credential.accountName)"

                        Write-Verbose "Backing up LAPS password for $($resultsJson.deviceName) ($($credential.accountName)) to Key Vault '$keyVaultName' with secret name '$secretName' and content type '$contentType'"
                        $existingVersions = Get-AzKeyVaultSecret -VaultName $keyVaultName -Name $secretName -IncludeVersions -ErrorAction SilentlyContinue
                        $alreadyBackedUp = $existingVersions | Where-Object { $_.ContentType -eq $contentType }
                        if ($alreadyBackedUp) {
                            Write-Host "Secret '$secretName' with ContentType '$contentType' already exists in Key Vault, skipping..." -ForegroundColor Yellow
                        }
                        else {
                            $secretValue = ConvertTo-SecureString $plainText -AsPlainText -Force
                            $notBefore = if ($credential.backupDateTime) { (Get-Date $credential.backupDateTime).ToUniversalTime() } else { $null }
                            $setParams = @{
                                VaultName   = $keyVaultName
                                Name        = $secretName
                                SecretValue = $secretValue
                                ContentType = $contentType
                                ErrorAction = 'Continue'
                            }
                            if ($notBefore) { $setParams['NotBefore'] = $notBefore }
                            $null = Set-AzKeyVaultSecret @setParams
                            $kvSecretCreationCount++
                            Write-Verbose "Successfully backed up LAPS password for $($resultsJson.deviceName) ($($credential.accountName)) to Key Vault"

                            # Throttle Key Vault writes: max ~300 creations per 10 seconds; pause every 250
                            if ($kvSecretCreationCount % 250 -eq 0) {
                                Write-Host "Key Vault rate limit throttle: $kvSecretCreationCount secrets created. Waiting 10 seconds..." -ForegroundColor Cyan
                                Start-Sleep -Seconds 10
                            }
                        }
                    }
                    catch {
                        Write-Warning "Failed to backup LAPS password to Key Vault: $($_.Exception.Message)"
                    }
                }
                else {
                    $object = [PSCustomObject][ordered]@{
                        DeviceName             = $resultsJson.deviceName
                        DeviceId               = $lapsDeviceId
                        Account                = $credential.accountName
                        IsCurrent              = ($credential -eq $currentCredential)
                        Password               = $plainText
                        PasswordExpirationTime = $lapsPasswordExpirationTime
                        PasswordUpdateTime     = if ($credential.backupDateTime) { Get-Date $credential.backupDateTime } else { $null }
                    }

                    $object
                }
            }
        }
        else {
            # Output a single object that just displays latest password expiration time
            # Note, $IncludeHistory is ignored even if specified in this case
            $object = [PSCustomObject][ordered]@{
                DeviceName             = $resultsJson.deviceName
                DeviceId               = $lapsDeviceId
                Password               = '[HIDDEN - Use -ShowPassword to display]'
                PasswordExpirationTime = $lapsPasswordExpirationTime
            }

            $object
        }
    }
}