Intune-Management.psm1

<#
    Module: Intune-Management.psm1
    Author: Thiago Beier
    Description: Functions for managing Intune ImportPFX Connector display name.
#>


function Get-IntuneConnectorServices {
    $results = Get-CimInstance -ClassName Win32_Service |
    Where-Object { $_.DisplayName -like "PFX*" } |
    Select-Object DisplayName, State, StartName

    if ($results) {
        Write-Host ""
        Write-Host "Intune Certificate Connector Services Status" -ForegroundColor Green
        $results | Format-Table -AutoSize
    }
    else {
        Write-Warning "Intune Certificate Connector Services Status not found."
    }
}

# Modules required by this module. Microsoft.Graph.Beta.DeviceManagement.Administration
# depends on Microsoft.Graph.Authentication, but both are listed so each is
# validated and, if needed, installed explicitly.
$script:RequiredGraphModules = @(
    'Microsoft.Graph.Authentication',
    'Microsoft.Graph.Beta.DeviceManagement.Administration'
)

# Validate and install everything required to talk to Microsoft Graph.
# By default this installs / uses the LATEST version published by Microsoft.
function Install-GraphPrerequisite {
    [CmdletBinding()]
    param (
        # Modules that must be present before connecting.
        [string[]]$RequiredModules = $script:RequiredGraphModules,

        # Optional pin. When omitted the latest available version is used/installed.
        [string]$RequiredVersion,

        # Force a check for (and install of) the newest version on the PSGallery.
        [switch]$Update
    )

    Write-Host "Validating prerequisites..." -ForegroundColor Cyan

    # Prefer machine-wide (AllUsers) install when elevated, so the modules land
    # in a path that is always on PSModulePath. A per-user (CurrentUser) install
    # can go to a redirected Documents folder that PS 5.1 does not search, which
    # makes the freshly "installed" module undiscoverable.
    $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
        ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    $installScope = if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' }
    Write-Host " Install scope: $installScope (elevated: $isAdmin)."

    # 1. TLS 1.2 - required by the PowerShell Gallery on Windows PowerShell 5.1.
    try {
        if ([Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') {
            [Net.ServicePointManager]::SecurityProtocol = `
                [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
            Write-Host " Enabled TLS 1.2 for this session."
        }
    }
    catch { Write-Warning " Could not enforce TLS 1.2: $_" }

    # 2. NuGet package provider - required by Install-Module.
    try {
        $nuget = Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue
        if (-not $nuget -or $nuget.Version -lt [version]'2.8.5.201') {
            Write-Host " Installing NuGet package provider..."
            Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope $installScope -ErrorAction Stop | Out-Null
        }
    }
    catch { Write-Warning " Could not install NuGet provider (offline?): $_" }

    # 3. Trust the PSGallery so installs run non-interactively.
    try {
        $gallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue
        if ($gallery -and $gallery.InstallationPolicy -ne 'Trusted') {
            Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop
            Write-Host " Set PSGallery as a trusted repository."
        }
    }
    catch { Write-Warning " Could not set PSGallery to Trusted: $_" }

    # 4. Ensure each required module is installed (latest unless pinned).
    foreach ($mod in $RequiredModules) {
        $installed = Get-Module -ListAvailable -Name $mod
        $needsInstall = $false

        if (-not $installed) {
            $needsInstall = $true
        }
        elseif ($RequiredVersion -and -not ($installed.Version -contains [version]$RequiredVersion)) {
            $needsInstall = $true
        }
        elseif ($Update) {
            # Compare highest installed against the gallery's latest.
            try {
                $latest = (Find-Module -Name $mod -ErrorAction Stop).Version
                $highest = ($installed.Version | Sort-Object -Descending)[0]
                if ([version]$latest -gt [version]$highest) { $needsInstall = $true }
            }
            catch { Write-Warning " Could not check latest version of $mod (offline?): $_" }
        }

        if ($needsInstall) {
            $target = if ($RequiredVersion) { "version $RequiredVersion" } else { "latest version" }
            Write-Host " Installing $mod ($target) to $installScope scope..."
            $installParams = @{
                Name        = $mod
                Force       = $true
                Scope       = $installScope
                AllowClobber = $true
                Confirm     = $false
                ErrorAction = 'Stop'
            }
            if ($RequiredVersion) { $installParams['RequiredVersion'] = $RequiredVersion }
            try {
                Install-Module @installParams
            }
            catch {
                Write-Error " Failed to install $mod. It is not present and could not be downloaded (offline?): $_"
                return $false
            }

            # Confirm the just-installed module is actually discoverable on the
            # module path. If a per-user install landed in a redirected folder
            # that is not on PSModulePath, retry once to AllUsers when elevated.
            $found = Get-Module -ListAvailable -Name $mod |
                Where-Object { -not $RequiredVersion -or $_.Version -eq [version]$RequiredVersion }
            if (-not $found -and $installScope -eq 'CurrentUser' -and $isAdmin) {
                Write-Warning " $mod not discoverable after CurrentUser install. Retrying to AllUsers..."
                $installParams['Scope'] = 'AllUsers'
                try { Install-Module @installParams } catch { Write-Error " Retry failed: $_"; return $false }
                $found = Get-Module -ListAvailable -Name $mod |
                    Where-Object { -not $RequiredVersion -or $_.Version -eq [version]$RequiredVersion }
            }
            if (-not $found) {
                Write-Error " $mod$(if($RequiredVersion){" v$RequiredVersion"}) installed but not found on PSModulePath. Check that '$installScope' module path is on `$env:PSModulePath."
                return $false
            }
        }
        else {
            $ver = ($installed.Version | Sort-Object -Descending)[0]
            Write-Host " $mod present (v$ver)."
        }
    }

    Write-Host "Prerequisites OK." -ForegroundColor Green
    return $true
}

# Import the required Graph modules and authenticate. Tries multiple sign-in
# methods so authentication succeeds across environments/versions.
function Connect-ToMsGraph {
    [CmdletBinding()]
    param (
        # Scopes required to read/update the Intune NDES/PFX connector.
        [string[]]$Scopes = @('DeviceManagementConfiguration.ReadWrite.All'),

        # Use device code flow first (useful on servers with no usable browser).
        [switch]$UseDeviceCode,

        # Optional pin. When omitted, the latest installed version is used on
        # PowerShell 7, and the last known-good 2.19.0 on Windows PowerShell 5.1
        # (where newer builds return an unusable token).
        [string]$GraphVersion,

        # Check the gallery for and install a newer Graph version before connecting.
        [switch]$Update
    )

    # On Windows PowerShell 5.1 (Desktop edition), Microsoft Graph SDK builds
    # AFTER 2.19.0 have a regression: Connect-MgGraph creates a context but the
    # token is unusable, so every subsequent Graph call re-triggers interactive
    # auth and fails. The latest SDK works on PowerShell 7 (Core). Unless the
    # caller pins a version, use the last known-good 2.19.0 on Desktop.
    $knownGoodVersion = '2.19.0'
    if (-not $GraphVersion -and $PSVersionTable.PSEdition -eq 'Desktop') {
        $GraphVersion = $knownGoodVersion
        Write-Host "Windows PowerShell 5.1 detected: using known-good Microsoft Graph v$knownGoodVersion (latest works on PowerShell 7)." -ForegroundColor Yellow
    }

    # 1. Validate and install prerequisites (latest by default).
    $prereqParams = @{}
    if ($GraphVersion) { $prereqParams['RequiredVersion'] = $GraphVersion }
    if ($Update)       { $prereqParams['Update'] = $true }
    if (-not (Install-GraphPrerequisite @prereqParams)) {
        Write-Error "Prerequisite validation failed. Aborting."
        return $false
    }

    # 2. Import the modules. A specific version is imported only when pinned;
    # otherwise the latest installed version is loaded.
    try {
        Write-Host "Importing Microsoft Graph modules..."
        foreach ($mod in $script:RequiredGraphModules) {
            if ($GraphVersion) {
                Import-Module $mod -RequiredVersion $GraphVersion -Force -ErrorAction Stop
            }
            else {
                Import-Module $mod -Force -ErrorAction Stop
            }
        }
    }
    catch {
        Write-Error "Failed to import Microsoft Graph modules: $_"
        return $false
    }

    # 3. Authenticate, then validate the token actually works. Some Graph SDK
    # builds create a context but return an unusable token, which only
    # surfaces on the next real request - so a live call is the only
    # reliable check.
    $authOk = Invoke-GraphSignIn -Scopes $Scopes -PreferDeviceCode:$UseDeviceCode
    if ($authOk) { $authOk = Test-GraphConnection }

    # If the token is unusable and the caller did not pin a version, retry once
    # with the known-good 2.19.0. (On Desktop we already default to 2.19.0, so
    # this mainly covers unexpected failures.)
    if (-not $authOk -and $GraphVersion -ne $knownGoodVersion) {
        Write-Warning "Microsoft Graph token is unusable. Retrying with known-good v$knownGoodVersion..."
        if (Install-GraphPrerequisite -RequiredVersion $knownGoodVersion) {
            try {
                try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { }
                foreach ($mod in $script:RequiredGraphModules) {
                    Remove-Module $mod -Force -ErrorAction SilentlyContinue
                    Import-Module $mod -RequiredVersion $knownGoodVersion -Force -ErrorAction Stop
                }
                $authOk = Invoke-GraphSignIn -Scopes $Scopes -PreferDeviceCode:$UseDeviceCode
                if ($authOk) { $authOk = Test-GraphConnection }
            }
            catch {
                Write-Warning "Fallback to v$knownGoodVersion failed: $_"
            }
        }
    }

    if (-not $authOk) {
        Write-Error ("Could not establish a WORKING Microsoft Graph connection. " +
            "On Windows PowerShell 5.1, open a FRESH window and run: " +
            "Update-IntuneConnectorDisplayName -GraphVersion $knownGoodVersion " +
            "- or run under PowerShell 7 with the latest Graph SDK.")
        return $false
    }

    # 4. Confirm we have a context with the required scopes.
    $context = $null
    try { $context = Get-MgContext -ErrorAction Stop } catch { $context = $null }
    if (-not $context) {
        Write-Error "Not connected to Microsoft Graph. Aborting."
        return $false
    }

    $stillMissing = $Scopes | Where-Object { $_ -notin $context.Scopes }
    if ($stillMissing) {
        Write-Warning "Connected, but the token may be missing scope(s): $($stillMissing -join ', ')."
    }

    Write-Host "Connected to Microsoft Graph as $($context.Account) (tenant $($context.TenantId))." -ForegroundColor Green
    return $true
}

# Validate that the current Graph token can make a real request. Some Graph SDK
# builds on Windows PowerShell 5.1 return an unusable token after Connect-MgGraph;
# the failure only surfaces on the next call, so probe with a lightweight request.
function Test-GraphConnection {
    [CmdletBinding()]
    param ()
    try {
        $null = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me' -ErrorAction Stop
        return $true
    }
    catch {
        Write-Warning "Microsoft Graph token is not usable: $($_.Exception.Message)"
        return $false
    }
}

# Resilient sign-in helper: reuses a valid session, otherwise tries device code
# and interactive browser flows until one succeeds.
function Invoke-GraphSignIn {
    [CmdletBinding()]
    param (
        [string[]]$Scopes,
        [switch]$PreferDeviceCode
    )

    # On PS 5.1, Get-MgContext throws 'SessionNotInitialized' instead of
    # returning $null when there is no active session. Wrap it so callers get
    # a clean $null.
    $existingContext = $null
    try { $existingContext = Get-MgContext -ErrorAction Stop } catch { $existingContext = $null }

    # A context can exist but lack the required scopes (e.g. left over from a
    # previous half-failed sign-in). Reusing it makes the Graph cmdlets fall
    # back to an interactive prompt, so force a clean reconnect instead.
    if ($existingContext) {
        $missing = $Scopes | Where-Object { $_ -notin $existingContext.Scopes }
        if (-not $missing) {
            Write-Host "Already authenticated to Microsoft Graph with required scopes." -ForegroundColor Green
            return $true
        }
        Write-Host "Existing Graph session is missing required scopes. Reconnecting..."
        try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { }
    }

    # Try each method until one produces a valid context. Always prompts the user.
    $methods = if ($PreferDeviceCode) {
        @(@{ Name = 'device code'; DeviceCode = $true }, @{ Name = 'interactive browser'; DeviceCode = $false })
    }
    else {
        @(@{ Name = 'interactive browser'; DeviceCode = $false }, @{ Name = 'device code'; DeviceCode = $true })
    }

    foreach ($method in $methods) {
        Write-Host "Connecting to Microsoft Graph using $($method.Name)..."
        if ($method.DeviceCode) {
            Write-Host "You will be prompted to sign in. Open https://microsoft.com/devicelogin and enter the code shown below." -ForegroundColor Yellow
        }

        try {
            $connectParams = @{
                Scopes    = $Scopes
                NoWelcome = $true
            }
            if ($method.DeviceCode) { $connectParams['UseDeviceCode'] = $true }

            Connect-MgGraph @connectParams

            $probe = $null
            try { $probe = Get-MgContext -ErrorAction Stop } catch { $probe = $null }
            if ($probe) { return $true }
        }
        catch {
            Write-Warning "Sign-in via $($method.Name) failed: $($_.Exception.Message)"
        }
    }

    return $false
}

function Get-IntuneCertificateConnectorInfo {
    [CmdletBinding()]
    param ()

    # Search for the connector
    Write-Host "Searching for Certificate Connector for Microsoft Intune..."
    $script:IntuneCertConnectorInfo = Get-ItemProperty HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\* |
    Where-Object { $_.DisplayName -eq "Certificate Connector for Microsoft Intune" } |
    Select-Object DisplayName, DisplayVersion, Publisher, InstallDate

    if ($script:IntuneCertConnectorInfo) {
        Write-Host "Certificate Connector for Microsoft Intune found."
    }
    else {
        Write-Host "Certificate Connector for Microsoft Intune not found."
        $script:IntuneCertConnectorInfo = @()
    } 
    return $script:IntuneCertConnectorInfo
}


# Example usage
#Get-IntuneCertificateConnectorInfo

# Now you can reuse $script:IntuneCertConnectorInfo in other functions or logic


function Update-IntuneConnectorDisplayName {
    [CmdletBinding()]
    param (
        # Use device code flow for sign-in (useful on servers with no browser).
        [switch]$UseDeviceCode,

        # Pin the Microsoft Graph SDK version used for sign-in (e.g. '2.19.0').
        [string]$GraphVersion,

        # Check the gallery for and install the newest Graph SDK before connecting.
        [switch]$Update
    )

    # Ensure the Microsoft Graph module is imported and we are authenticated.
    $connectSplat = @{ UseDeviceCode = $UseDeviceCode }
    if ($GraphVersion) { $connectSplat['GraphVersion'] = $GraphVersion }
    if ($Update)       { $connectSplat['Update'] = $true }
    $connected = Connect-ToMsGraph @connectSplat
    if (-not $connected) {
        Write-Warning "Aborting: could not authenticate to Microsoft Graph."
        return
    }

    # The registry holds the thumbprint of the encryption certificate this
    # server's connector is actively using. This is the authoritative way to
    # identify THIS connector, especially after an uninstall/reinstall leaves
    # stale "Microsoft Intune ImportPFX Connector CA" certificates behind.
    $registryKey = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\MicrosoftIntune\PFXCertificateConnector" -ErrorAction SilentlyContinue
    $registryThumbprint = $registryKey.EncryptionCertThumbprint

    Write-Host ""

    if (-not $registryThumbprint) {
        Write-Warning "Intune Certificate Connector not installed on this server (registry EncryptionCertThumbprint not found)."
        Write-Host ""
        return
    }

    # Find the certificate that matches the registry thumbprint (rather than
    # guessing the 'newest' connector cert, which is unreliable when several
    # connector certs are present from previous installs).
    $result = Get-ChildItem -Path Cert:\LocalMachine\My |
    Where-Object { $_.Thumbprint -eq $registryThumbprint } |
    Select-Object -First 1 -Property NotBefore, NotAfter, Subject, Thumbprint

    if (-not $result) {
        Write-Warning "No certificate in LocalMachine\My matches the connector thumbprint '$registryThumbprint'. The connector certificate may be missing."
        Write-Host ""
        return
    }

    Write-Host "Certificate thumbprint matches registry key ($registryThumbprint)."

    # The certificate subject CN is this connector's NDES/PFX connector ID.
    $ndesConnectorId = $result.Subject -replace "^CN=", ""
    Write-Host "Connector ID for this server: $ndesConnectorId"

    # Get connector info from Microsoft Graph Beta API
    $intuneCertConnector = Get-MgBetaDeviceManagementNdeConnector -NdesConnectorId $ndesConnectorId -ErrorAction SilentlyContinue
    if (-not $intuneCertConnector) {
        Write-Warning "Connector '$ndesConnectorId' was not found in Intune. Verify you signed in to the correct tenant."
        Write-Host ""
        return
    }
    Write-Host "Existing Connector DisplayName: $($intuneCertConnector.DisplayName)"

    # Check if the connector ID matches
    if ($intuneCertConnector.Id -eq $ndesConnectorId) {
        if ($intuneCertConnector.DisplayName -like "*$env:COMPUTERNAME*") {
            Write-Host "Intune Certificate Connector is up to date"
        }
        else {
            $combined = "$env:COMPUTERNAME" + "_" + $intuneCertConnector.DisplayName

            $params = @{
                id          = $intuneCertConnector.Id
                displayName = $combined
            }

            Write-Host "Updating Intune Certificate Connector DisplayName to '$combined'..."
            Update-MgBetaDeviceManagementNdeConnector -NdesConnectorId $ndesConnectorId -BodyParameter $params

            Write-Host "Updated Connector DisplayName to '$combined'." -ForegroundColor Green
        }
    }
    else {
        Write-Warning "Connector ID mismatch."
    }
    Write-Host ""
}

# NOTE: Do NOT run any commands on import.
# This module only defines functions.
# Run installation, import, and authentication commands outside the module.