<# .SYNOPSIS Cleans up existing device objects (Intune, Autopilot, Entra ID) and registers the device in Windows Autopilot for Homburger during OSDCloud OOBE, adds it to Entra ID groups and cleans up certificate and scripts. .DESCRIPTION https://autopilot.homburger.osdcloud.ch Runs as SYSTEM during OSDCloud OOBE, before the Autopilot process. Flow: 1. Imports the PFX certificate (Import-Certificate.ps1) and connects to Graph with cert-based auth. 2. Reads AddToGroup and AssignedComputerName from C:\ProgramData\OSDeploy\OSDeploy.AutopilotOOBE.json. 3. Cleanup on every run ($CleanupBeforeStaging): checks Intune, Autopilot and Entra ID for objects of this device and deletes every object found, in the order Intune, Autopilot, Entra ID. Nothing is kept or restored (group tag, assigned user, group memberships, BitLocker keys and LAPS password of the old objects are gone). If an Autopilot record was deleted, the script waits until it is gone before the import. Safety guards: exact serial match, local model must match, generic serial numbers skip the cleanup, more than one Autopilot record or Entra ID match aborts the run, hybrid joined Entra ID objects are not deleted. 4. Imports the hardware hash, waits for sync, adds the device to AddToGroup, sets the computer name and waits for the profile assignment. 5. Adds the device to the Entra ID group in $CtxStagingGroup. 6. Always (finally): disconnects Graph, deletes certificate including private key, removes Import-Certificate.ps1, writes a status report and stops the transcript. Logs: C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\OSD\-Autopilot-Tasks.log Status report: same folder, -AutopilotStatus.json Required Graph application permissions: DeviceManagementServiceConfig.ReadWrite.All, DeviceManagementManagedDevices.ReadWrite.All, Device.ReadWrite.All, GroupMember.ReadWrite.All (group lookup by name, Group.Read.All if the tenant requires it). The script sets no exit code on purpose: it runs via irm | iex in the OOBE console, an exit would close that console. Result is written to the status report: completed, completedWithErrors or failed. The wait loops have upper limits ($ImportTimeoutMinutes, $SyncTimeoutMinutes, $AssignTimeoutMinutes). .NOTES Version : 1.3.0 Creation Date : 2024-10-30 Author : Ákos Bakos Company : SmartCon GmbH Contact : akos.bakos@smartcon.ch Copyright : (c) 2026 SmartCon GmbH. All rights reserved. HISTORY Date By Comments --------------- --------------- -------------------------------------------------- 2024-10-30 Ákos Bakos Script created 2025-02-21 Ákos Bakos AddToGroup logic change to using Graph API 2026-01-23 Ákos Bakos Adding device to Entra ID group logic (AZ_COM_CtxStaging) 2026-10-23 Ákos Bakos V3: cleanup of existing Intune, Autopilot and Entra ID objects before every staging, Graph errors no longer reported as success, wait loop timeouts, status report JSON, cleanup in finally incl. private key, loop fixes #> #region Configuration $ScriptVersion = '1.3.0' $CertificateSubjectName = 'HomburgerAutopilotRegistration' $TenantID = 'e913979f-1c7b-4ad5-9231-f78878b796ae' $AppID = 'c7c98130-9bcf-449b-b7b1-1964af429e5b' $CtxStagingGroup = 'AZ_COM_CtxStaging' # Check and delete existing objects of this device (Intune, Autopilot, Entra ID) before every staging $CleanupBeforeStaging = $true # Seconds to wait after a deleted Autopilot record is gone, before the import starts $CleanupGraceSeconds = 60 # Upper limits for the wait loops. On timeout the run fails, the finally block still removes the certificate. $ImportTimeoutMinutes = 30 $SyncTimeoutMinutes = 30 $AssignTimeoutMinutes = 60 #endregion #region Helper Functions function Write-DarkGrayDate { [CmdletBinding()] param ( [Parameter(Position=0)] [System.String] $Message ) if ($Message) { Write-Host -ForegroundColor DarkGray "$((Get-Date).ToString('yyyy-MM-dd-HHmmss')) $Message" } else { Write-Host -ForegroundColor DarkGray "$((Get-Date).ToString('yyyy-MM-dd-HHmmss')) " -NoNewline } } function Write-DarkGrayHost { [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [System.String] $Message ) Write-Host -ForegroundColor DarkGray $Message } function Write-DarkGrayLine { [CmdletBinding()] param () Write-Host -ForegroundColor DarkGray "=========================================================================" } function Write-SectionHeader { [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [System.String] $Message ) Write-DarkGrayLine Write-DarkGrayDate Write-Host -ForegroundColor Cyan $Message } function Write-SectionSuccess { [CmdletBinding()] param ( [Parameter(Position=0)] [System.String] $Message = 'Success!' ) Write-DarkGrayDate Write-Host -ForegroundColor Green $Message } function Get-GraphErrorText { [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0)] $ErrorRecord ) # Invoke-MgGraphRequest puts the response body either into the exception message or into ErrorDetails "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)" } function Get-AutopilotIdentityBySerial { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [System.String] $SerialNumber ) # contains() on the first token, exact match client side (serial numbers of VMs can contain spaces) $SerialNumber = $SerialNumber.Trim() $token = [uri]::EscapeDataString($SerialNumber.Split(' ')[0].Replace("'", "''")) $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$token')" $result = @() while ($uri) { $response = Invoke-MgGraphRequest -Uri $uri -Method GET -OutputType PSObject -ErrorAction Stop $result += @($response.value | Where-Object { $_.serialNumber -eq $SerialNumber }) $uri = $response.'@odata.nextLink' } , $result } function Get-AutopilotAzureAdDeviceId { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [System.String] $SerialNumber, [System.Int32] $MaxAttempts = 20, [System.Int32] $DelaySeconds = 15 ) for ($i = 1; $i -le $MaxAttempts; $i++) { try { $identities = Get-AutopilotIdentityBySerial -SerialNumber $SerialNumber if ($identities.Count -gt 1) { throw "Multiple Autopilot devices found with serial '$SerialNumber'. Aborting to avoid wrong membership." } if ($identities.Count -eq 1 -and $identities[0].azureActiveDirectoryDeviceId -and $identities[0].azureActiveDirectoryDeviceId -ne '00000000-0000-0000-0000-000000000000') { return $identities[0].azureActiveDirectoryDeviceId } } catch { if ("$_" -match 'Multiple Autopilot devices') { throw } Write-Warning "Autopilot lookup attempt $i failed: $(Get-GraphErrorText $_)" } Start-Sleep -Seconds $DelaySeconds } throw "Unable to resolve azureActiveDirectoryDeviceId from Autopilot identity for serial '$SerialNumber'." } function Add-DeviceToEntraGroup { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [System.String] $GroupName, [Parameter(Mandatory=$true)] [System.String] $AzureAdDeviceId, [System.Int32] $MaxAttempts = 5, [System.Int32] $DelaySeconds = 15 ) # Returns 'added' or 'alreadyMember', throws on every other outcome $escapedGroupName = [uri]::EscapeDataString($GroupName.Trim().Replace("'", "''")) $groupUri = "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$escapedGroupName'&`$select=id,displayName,groupTypes" $groups = @((Invoke-MgGraphRequest -Uri $groupUri -Method GET -OutputType PSObject -ErrorAction Stop).value) if ($groups.Count -eq 0) { throw "Unable to find Entra ID group '$GroupName'." } if ($groups.Count -gt 1) { throw "Multiple Entra ID groups named '$GroupName' found. Aborting to avoid wrong membership." } $group = $groups[0] if ($group.groupTypes -contains 'DynamicMembership') { throw "Group '$($group.displayName)' is a dynamic group. You cannot add members manually." } if ([string]::IsNullOrWhiteSpace($AzureAdDeviceId) -or $AzureAdDeviceId -eq '00000000-0000-0000-0000-000000000000') { throw "No valid Entra ID deviceId available (value: '$AzureAdDeviceId')." } # Retry: the Entra ID device object can lag behind the Autopilot sync for ($i = 1; $i -le $MaxAttempts; $i++) { try { $devUri = "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$AzureAdDeviceId'&`$select=id,displayName" $devices = @((Invoke-MgGraphRequest -Uri $devUri -Method GET -OutputType PSObject -ErrorAction Stop).value) if ($devices.Count -gt 1) { throw "Multiple Entra ID devices matched deviceId '$AzureAdDeviceId'. Aborting to avoid wrong membership." } if ($devices.Count -eq 0) { throw "Unable to find Entra ID device with deviceId '$AzureAdDeviceId'." } $aadDevice = $devices[0] $addUri = "https://graph.microsoft.com/v1.0/groups/$($group.id)/members/`$ref" $body = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($aadDevice.id)" } | ConvertTo-Json try { Invoke-MgGraphRequest -Method POST -Uri $addUri -Body $body -ContentType 'application/json' -ErrorAction Stop | Out-Null Write-SectionSuccess "Added device '$($aadDevice.displayName)' to Entra ID group '$($group.displayName)'" return 'added' } catch { if ((Get-GraphErrorText $_) -match 'already exist') { Write-DarkGrayHost "Already a member: $($aadDevice.displayName) -> $($group.displayName)" return 'alreadyMember' } throw } } catch { $errorText = Get-GraphErrorText $_ # No retry on ambiguity or missing permissions if ($i -ge $MaxAttempts -or $errorText -match 'Multiple Entra ID devices|Authorization_RequestDenied|Forbidden|403') { throw } Write-Warning "Group add attempt $i of $MaxAttempts failed: $errorText" Start-Sleep -Seconds $DelaySeconds } } } function Remove-ExistingDeviceObjects { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [System.String] $SerialNumber, [System.Int32] $GraceSeconds = 60, [System.Int32] $MaxWaitAttempts = 30, [System.Int32] $WaitSeconds = 20 ) # Checks Intune, Autopilot and Entra ID for objects of this device and deletes every object found, # in the order Intune, Autopilot, Entra ID. Every step is logged into $Global:AutopilotStatus.Cleanup, # so a partial run stays visible in the status report. Ambiguous states throw. $log = $Global:AutopilotStatus.Cleanup $log.Status = 'running' $nullGuid = '00000000-0000-0000-0000-000000000000' # Generic or empty serial numbers would match unrelated devices: skip the cleanup, staging continues $genericSerials = @('Default string', 'DefaultString', 'To be filled by O.E.M.', 'System Serial Number', 'Chassis Serial Number', 'SerialNumber', 'Serial Number', 'Not Specified', 'None', 'Not Available', 'N/A', 'NA', 'Unknown', 'Invalid', '123456789', '0123456789', '1234567890') $SerialNumber = "$SerialNumber".Trim() if ($SerialNumber.Length -lt 5 -or $genericSerials -contains $SerialNumber -or $SerialNumber -match '^(.)\1+$') { Write-Warning "Serial number '$SerialNumber' is empty, too short or generic. Cleanup skipped." $log.Status = 'skippedGenericSerial' return } $localModel = "$((Get-CimInstance -ClassName Win32_ComputerSystem).Model)".Trim() $entraDeviceIds = @() # Autopilot record (read first, it carries the Entra ID device id) $identities = Get-AutopilotIdentityBySerial -SerialNumber $SerialNumber if ($identities.Count -gt 1) { throw "Multiple Autopilot devices found with serial '$SerialNumber'. Cleanup aborted, please check manually." } $identity = $null if ($identities.Count -eq 1) { $identity = $identities[0] $log.AutopilotDeviceId = $identity.id Write-Host "Autopilot device found: serial $($identity.serialNumber), $($identity.manufacturer) $($identity.model), last contact $($identity.lastContactedDateTime)" if ($identity.model -and $localModel -and "$($identity.model)".Trim() -ne $localModel) { throw "Autopilot model '$($identity.model)' does not match local model '$localModel'. Cleanup aborted." } } else { Write-DarkGrayHost "No Autopilot device found with serial '$SerialNumber'" } # 1. Intune: exact serial match and same model as this device $escapedSerial = [uri]::EscapeDataString($SerialNumber.Replace("'", "''")) $mdUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$escapedSerial'&`$select=id,deviceName,serialNumber,model,azureADDeviceId" $managedDevices = @((Invoke-MgGraphRequest -Uri $mdUri -Method GET -OutputType PSObject -ErrorAction Stop).value | Where-Object { "$($_.serialNumber)".Trim() -eq $SerialNumber }) foreach ($md in $managedDevices) { $mdModel = "$($md.model)".Trim() if (-not $mdModel -or -not $localModel -or $mdModel -ne $localModel) { Write-Warning "Intune device $($md.deviceName) ($($md.id)) has the same serial but model '$mdModel' instead of '$localModel'. Not deleted." $log.IntuneDeviceIdsSkipped += $md.id continue } Write-Host "Deleting Intune device $($md.deviceName) ($($md.id))" Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$($md.id)" -Method DELETE -ErrorAction Stop | Out-Null $log.IntuneDeviceIds += $md.id $entraDeviceIds += $md.azureADDeviceId } if ($managedDevices.Count -eq 0) { Write-DarkGrayHost "No Intune device found with serial '$SerialNumber'" } # 2. Autopilot if ($identity) { Write-Host "Deleting Autopilot device $($identity.id)" Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities/$($identity.id)" -Method DELETE -ErrorAction Stop | Out-Null $log.AutopilotDeleteRequested = $true $entraDeviceIds += $identity.azureActiveDirectoryDeviceId } # 3. Entra ID: the device objects referenced by the deleted Intune and Autopilot objects $entraDeviceIds = @($entraDeviceIds | Where-Object { $_ -and $_ -ne $nullGuid } | Select-Object -Unique) foreach ($entraDeviceId in $entraDeviceIds) { $devUri = "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$entraDeviceId'&`$select=id,displayName,deviceId,trustType" $aadDevices = @((Invoke-MgGraphRequest -Uri $devUri -Method GET -OutputType PSObject -ErrorAction Stop).value) if ($aadDevices.Count -gt 1) { throw "Multiple Entra ID devices matched deviceId '$entraDeviceId'. Cleanup aborted, please check manually." } if ($aadDevices.Count -eq 0) { Write-DarkGrayHost "No Entra ID device found with deviceId '$entraDeviceId'" continue } if ($aadDevices[0].trustType -eq 'ServerAd') { # Hybrid joined objects are synced from Active Directory, a delete in Entra ID would come back Write-Warning "Entra ID device $($aadDevices[0].displayName) is hybrid joined (synced from AD). Not deleted." $log.EntraObjectIdsSkipped += $aadDevices[0].id continue } Write-Host "Deleting Entra ID device $($aadDevices[0].displayName) ($($aadDevices[0].id))" Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/devices/$($aadDevices[0].id)" -Method DELETE -ErrorAction Stop | Out-Null $log.EntraObjectIds += $aadDevices[0].id } # Wait until a deleted Autopilot record is really gone, otherwise the import fails if ($identity) { $gone = $false for ($i = 1; $i -le $MaxWaitAttempts -and -not $gone; $i++) { $remaining = $null try { $remaining = Get-AutopilotIdentityBySerial -SerialNumber $SerialNumber } catch { Write-Warning "Autopilot lookup attempt $i failed: $(Get-GraphErrorText $_)" } if ($null -ne $remaining -and $remaining.Count -eq 0) { $gone = $true } else { Write-Host "Waiting for Autopilot deletion ($i of $MaxWaitAttempts)" Start-Sleep -Seconds $WaitSeconds } } if (-not $gone) { throw "Autopilot device with serial '$SerialNumber' still present after $($MaxWaitAttempts * $WaitSeconds) seconds. Import aborted." } Write-Host "Autopilot device removed. Waiting $GraceSeconds seconds before import" Start-Sleep -Seconds $GraceSeconds } if ($log.AutopilotDeleteRequested -or $log.IntuneDeviceIds.Count -gt 0 -or $log.EntraObjectIds.Count -gt 0) { $log.Status = 'deleted' Write-SectionSuccess "Existing objects of serial '$SerialNumber' deleted" } else { $log.Status = 'nothingFound' Write-DarkGrayHost "No existing objects to delete for serial '$SerialNumber'" } } #endregion $Title = "Autopilot Tasks" $host.UI.RawUI.WindowTitle = $Title [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [System.Net.WebRequest]::DefaultWebProxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials $env:APPDATA = "C:\Windows\System32\Config\SystemProfile\AppData\Roaming" $env:LOCALAPPDATA = "C:\Windows\System32\Config\SystemProfile\AppData\Local" $Env:PSModulePath = $env:PSModulePath+";C:\Program Files\WindowsPowerShell\Scripts" $env:Path = $env:Path+";C:\Program Files\WindowsPowerShell\Scripts" $LogDir = "$env:ProgramData\Microsoft\IntuneManagementExtension\Logs\OSD" if (-not (Test-Path -Path $LogDir)) { New-Item -Path $LogDir -ItemType Directory -Force | Out-Null } $RunStamp = (Get-Date).ToString('yyyy-MM-dd-HHmmss') $Global:Transcript = "$RunStamp-Autopilot-Tasks.log" Start-Transcript -Path (Join-Path $LogDir $Global:Transcript) -ErrorAction Ignore | Out-Null $Global:AutopilotStatus = [ordered]@{ Timestamp = (Get-Date).ToString('s') ScriptVersion = $ScriptVersion SerialNumber = (Get-CimInstance -ClassName Win32_BIOS).SerialNumber Result = 'running' Cleanup = [ordered]@{ Enabled = $CleanupBeforeStaging Status = if ($CleanupBeforeStaging) { 'pending' } else { 'skipped' } AutopilotDeviceId = $null AutopilotDeleteRequested = $false IntuneDeviceIds = @() IntuneDeviceIdsSkipped = @() EntraObjectIds = @() EntraObjectIdsSkipped = @() } Import = [ordered]@{ Status = 'pending'; ErrorCode = $null; ErrorName = $null } AutopilotDeviceId = $null AzureAdDeviceId = $null AssignedComputerName = $null AddToGroup = [ordered]@{ Group = $null; Status = 'skipped'; Error = $null } ProfileAssignment = 'pending' CtxStaging = [ordered]@{ Group = $CtxStagingGroup; Status = if ($CtxStagingGroup) { 'pending' } else { 'skipped' }; Error = $null } CertificateRemoved = $null Errors = @() } Function CustomWindowsAutopilotInfo { <# .SYNOPSIS Retrieves the Windows AutoPilot deployment details from one or more computers MIT LICENSE Copyright (c) 2023 Microsoft Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. .DESCRIPTION This script uses WMI to retrieve properties needed for a customer to register a device with Windows Autopilot. Note that it is normal for the resulting CSV file to not collect a Windows Product ID (PKID) value since this is not required to register a device. Only the serial number and hardware hash will be populated. .PARAMETER Online Add computers to Windows Autopilot via the Intune Graph API .PARAMETER AssignedComputerName An optional value specifying the computer name to be assigned to the device. This can only be specified with the -Online switch and only works with AAD join scenarios. .PARAMETER AddToGroup Specifies the name of the Azure AD group that the new device should be added to. .PARAMETER Assign Wait for the Autopilot profile assignment. (This can take a while for dynamic groups.) .PARAMETER Reboot Reboot the device after the Autopilot profile has been assigned (necessary to download the profile and apply the computer name, if specified). .PARAMETER CleanupBeforeStaging Deletes existing Intune, Autopilot and Entra ID objects of the device before the import. .EXAMPLE CustomWindowsAutopilotInfo -Online -Assign -AddToGroup 'Group' -TenantId '' -AppId '' #> [CmdletBinding(DefaultParameterSetName = 'Default')] param( [Parameter(Mandatory = $False, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, Position = 0)][alias("DNSHostName", "ComputerName", "Computer")] [String[]] $Name = @("localhost"), [Parameter(Mandatory = $False)] [String] $OutputFile = "", [Parameter(Mandatory = $False)] [String] $GroupTag = "", [Parameter(Mandatory = $False)] [String] $AssignedUser = "", [Parameter(Mandatory = $False)] [Switch] $Append = $false, [Parameter(Mandatory = $False)] [System.Management.Automation.PSCredential] $Credential = $null, [Parameter(Mandatory = $False)] [Switch] $Partner = $false, [Parameter(Mandatory = $False)] [Switch] $Force = $false, [Parameter(Mandatory = $True, ParameterSetName = 'Online')] [Switch] $Online = $false, [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [String] $TenantId = "", [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [String] $AppId = "", [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [String] $AppSecret = "", [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [String] $AddToGroup = "", [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [String] $AssignedComputerName = "", [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [Switch] $Assign = $false, [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [Switch] $Reboot = $false, [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [Switch] $CleanupBeforeStaging = $false, [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [Int] $CleanupGraceSeconds = 60, [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [Int] $ImportTimeoutMinutes = 30, [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [Int] $SyncTimeoutMinutes = 30, [Parameter(Mandatory = $False, ParameterSetName = 'Online')] [Int] $AssignTimeoutMinutes = 60 ) Begin { # Initialize empty list $computers = @() # If online, make sure we are able to authenticate if ($Online) { # Get NuGet $provider = Get-PackageProvider NuGet -ErrorAction Ignore if (-not $provider) { Write-Host "Installing provider NuGet with ForceBootstrap" Find-PackageProvider -Name NuGet -ForceBootstrap -IncludeDependencies } # Get WindowsAutopilotIntune module (and dependencies) $module = Import-Module WindowsAutopilotIntune -PassThru -ErrorAction Ignore if (-not $module) { Write-Host "Installing module WindowsAutopilotIntune" Install-Module WindowsAutopilotIntune -Force | Out-Null } Import-Module WindowsAutopilotIntune -Scope Global # Get Graph Authentication module (and dependencies) # Group and device lookups use Invoke-MgGraphRequest, the Groups and DirectoryManagement modules are no longer needed $module = Import-Module microsoft.graph.authentication -PassThru -ErrorAction Ignore if (-not $module) { Write-Host "Installing module microsoft.graph.authentication" Install-Module microsoft.graph.authentication -Force | Out-Null } Import-Module microsoft.graph.authentication -Scope Global # Connect if ($AppId -ne "") { $graph = Connect-MgGraph -Tenant $TenantId -AppId $AppId -Certificate $cert Write-Host "Connected to Intune tenant " -NoNewline Write-Host "$TenantId " -ForegroundColor Yellow -NoNewline Write-Host "using cert-based authentication" } else { # Comment this scope based call, due to 120sec timeout issue Connect-MgGraph -Scopes "DeviceManagementServiceConfig.ReadWrite.All", "Device.ReadWrite.All", "Group.ReadWrite.All" # Connect-MgGraph $graph = Get-MgContext Write-Host "Connected to Intune tenant" $graph.TenantId } # Force the output to a file if ($OutputFile -eq "") { $OutputFile = "$($env:TEMP)\autopilot.csv" } } } Process { foreach ($comp in $Name) { $bad = $false # Get a CIM session if ($comp -eq "localhost") { $session = New-CimSession } else { $session = New-CimSession -ComputerName $comp -Credential $Credential } # Get the common properties. Write-Verbose "Checking $comp" $serial = (Get-CimInstance -CimSession $session -Class Win32_BIOS).SerialNumber # Get the hash (if available) $devDetail = (Get-CimInstance -CimSession $session -Namespace root/cimv2/mdm/dmmap -Class MDM_DevDetail_Ext01 -Filter "InstanceID='Ext' AND ParentID='./DevDetail'") if ($devDetail -and (-not $Force)) { $hash = $devDetail.DeviceHardwareData } else { $bad = $true $hash = "" } # If the hash isn't available, get the make and model if ($bad -or $Force) { $cs = Get-CimInstance -CimSession $session -Class Win32_ComputerSystem $make = $cs.Manufacturer.Trim() $model = $cs.Model.Trim() if ($Partner) { $bad = $false } } else { $make = "" $model = "" } # Getting the PKID is generally problematic for anyone other than OEMs, so let's skip it here $product = "" # Depending on the format requested, create the necessary object if ($Partner) { # Create a pipeline object $c = New-Object psobject -Property @{ "Device Serial Number" = $serial "Windows Product ID" = $product "Hardware Hash" = $hash "Manufacturer name" = $make "Device model" = $model } # From spec: # "Manufacturer Name" = $make # "Device Name" = $model } else { # Create a pipeline object $c = New-Object psobject -Property @{ "Device Serial Number" = $serial "Windows Product ID" = $product "Hardware Hash" = $hash } if ($GroupTag -ne "") { Add-Member -InputObject $c -NotePropertyName "Group Tag" -NotePropertyValue $GroupTag } if ($AssignedUser -ne "") { Add-Member -InputObject $c -NotePropertyName "Assigned User" -NotePropertyValue $AssignedUser } } # Write the object to the pipeline or array if ($bad) { # Report an error when the hash isn't available Write-Error -Message "Unable to retrieve device hardware data (hash) from computer $comp" -Category DeviceError } elseif ($OutputFile -eq "") { $c } else { $computers += $c Write-Host "Gathered details for device with serial number: " -NoNewline Write-Host "$serial" -ForegroundColor Yellow } Remove-CimSession $session } } End { if ($OutputFile -ne "") { if ($Append) { if (Test-Path $OutputFile) { $computers += Import-Csv -Path $OutputFile } } if ($Partner) { $computers | Select-Object "Device Serial Number", "Windows Product ID", "Hardware Hash", "Manufacturer name", "Device model" | ConvertTo-Csv -NoTypeInformation | ForEach-Object { $_ -replace '"', '' } | Out-File $OutputFile } elseif ($AssignedUser -ne "") { $computers | Select-Object "Device Serial Number", "Windows Product ID", "Hardware Hash", "Group Tag", "Assigned User" | ConvertTo-Csv -NoTypeInformation | ForEach-Object { $_ -replace '"', '' } | Out-File $OutputFile } elseif ($GroupTag -ne "") { $computers | Select-Object "Device Serial Number", "Windows Product ID", "Hardware Hash", "Group Tag" | ConvertTo-Csv -NoTypeInformation | ForEach-Object { $_ -replace '"', '' } | Out-File $OutputFile } else { $computers | Select-Object "Device Serial Number", "Windows Product ID", "Hardware Hash" | ConvertTo-Csv -NoTypeInformation | ForEach-Object { $_ -replace '"', '' } | Out-File $OutputFile } } if ($Online) { # Check and delete existing objects of the device first (Intune, Autopilot, Entra ID) if ($CleanupBeforeStaging) { Write-SectionHeader "Cleanup of existing Intune, Autopilot and Entra ID objects" $computers | ForEach-Object { Remove-ExistingDeviceObjects -SerialNumber $_.'Device Serial Number' -GraceSeconds $CleanupGraceSeconds } } # Add the devices $importStart = Get-Date $imported = @() $computers | ForEach-Object { $imported += Add-AutopilotImportedDevice -serialNumber $_.'Device Serial Number' -hardwareIdentifier $_.'Hardware Hash' -groupTag $_.'Group Tag' -assignedUser $_.'Assigned User' } # Wait until the devices have been imported $processingCount = 1 while ($processingCount -gt 0) { $current = @() $processingCount = 0 $imported | ForEach-Object { $device = Get-AutopilotImportedDevice -id $_.id if ($device.state.deviceImportStatus -eq "unknown") { $processingCount = $processingCount + 1 } $current += $device } $deviceCount = $imported.Length Write-Host "Waiting for $processingCount of $deviceCount to be imported" if ($processingCount -gt 0) { if (((Get-Date) - $importStart).TotalMinutes -gt $ImportTimeoutMinutes) { $Global:AutopilotStatus.Import.Status = 'timeout' throw "Autopilot import not finished after $ImportTimeoutMinutes minutes." } Start-Sleep 30 } } $importDuration = (Get-Date) - $importStart $importSeconds = [Math]::Ceiling($importDuration.TotalSeconds) $successCount = 0 $current | ForEach-Object { Write-Host "$($_.serialNumber): $($_.state.deviceImportStatus) $($_.state.deviceErrorCode) $($_.state.deviceErrorName)" $Global:AutopilotStatus.Import = [ordered]@{ Status = $_.state.deviceImportStatus ErrorCode = $_.state.deviceErrorCode ErrorName = $_.state.deviceErrorName } if ($_.state.deviceImportStatus -eq "complete") { $successCount = $successCount + 1 } } Write-Host "$successCount devices imported successfully. Elapsed time to complete import: $importSeconds seconds" if ($successCount -lt $imported.Length) { Write-Warning "Import not complete for $($imported.Length - $successCount) device(s). Group assignment and profile wait are skipped for them." } # Wait until the devices can be found in Intune (should sync automatically) $syncStart = Get-Date $processingCount = 1 while ($processingCount -gt 0) { $autopilotDevices = @() $processingCount = 0 $current | ForEach-Object { if ($_.state.deviceImportStatus -eq "complete") { $apDevice = Get-AutopilotDevice -id $_.state.deviceRegistrationId if (-not $apDevice) { $processingCount = $processingCount + 1 } else { $autopilotDevices += $apDevice } } } $deviceCount = $autopilotDevices.Length Write-Host "Waiting for $processingCount of $deviceCount to be synced" if ($processingCount -gt 0) { if (((Get-Date) - $syncStart).TotalMinutes -gt $SyncTimeoutMinutes) { throw "Autopilot device not synced after $SyncTimeoutMinutes minutes." } Start-Sleep 30 } } $syncDuration = (Get-Date) - $syncStart $syncSeconds = [Math]::Ceiling($syncDuration.TotalSeconds) Write-Host "All devices synced. Elapsed time to complete sync: $syncSeconds seconds" $autopilotDevices | ForEach-Object { $Global:AutopilotStatus.AutopilotDeviceId = $_.id $Global:AutopilotStatus.AzureAdDeviceId = $_.azureActiveDirectoryDeviceId } # Add the device to the specified AAD group if ($AddToGroup) { $Global:AutopilotStatus.AddToGroup.Group = $AddToGroup $Global:AutopilotStatus.AddToGroup.Status = 'pending' $autopilotDevices | ForEach-Object { $aadDeviceId = $_.azureActiveDirectoryDeviceId try { $Global:AutopilotStatus.AddToGroup.Status = Add-DeviceToEntraGroup -GroupName $AddToGroup -AzureAdDeviceId $aadDeviceId } catch { $Global:AutopilotStatus.AddToGroup.Status = 'failed' $Global:AutopilotStatus.AddToGroup.Error = Get-GraphErrorText $_ Write-Error "AddToGroup '$AddToGroup' failed for $($aadDeviceId): $(Get-GraphErrorText $_)" } } } # Assign the computer name if ($AssignedComputerName -ne "") { $Global:AutopilotStatus.AssignedComputerName = $AssignedComputerName $autopilotDevices | ForEach-Object { Set-AutopilotDevice -id $_.id -displayName $AssignedComputerName } } # Wait for assignment (if specified) if ($Assign -and $autopilotDevices.Length -gt 0) { $assignStart = Get-Date $processingCount = 1 while ($processingCount -gt 0) { $processingCount = 0 $autopilotDevices | ForEach-Object { $apDevice = Get-AutopilotDevice -id $_.id -expand $assignStatus = "$($apDevice.deploymentProfileAssignmentStatus)" $Global:AutopilotStatus.ProfileAssignment = $assignStatus if (-not ($assignStatus.StartsWith("assigned"))) { $processingCount = $processingCount + 1 } } $deviceCount = $autopilotDevices.Length Write-Host "Waiting for $processingCount of $deviceCount to be assigned" if ($processingCount -gt 0) { if (((Get-Date) - $assignStart).TotalMinutes -gt $AssignTimeoutMinutes) { throw "Autopilot profile not assigned after $AssignTimeoutMinutes minutes (status: $($Global:AutopilotStatus.ProfileAssignment))." } Start-Sleep 30 } } $assignDuration = (Get-Date) - $assignStart $assignSeconds = [Math]::Ceiling($assignDuration.TotalSeconds) Write-Host "Profiles assigned to all devices. Elapsed time to complete assignment: $assignSeconds seconds" if ($Reboot) { Restart-Computer -Force } } elseif ($Assign) { $Global:AutopilotStatus.ProfileAssignment = 'skipped (no synced Autopilot device)' Write-Warning "No synced Autopilot device, profile assignment wait skipped" } } } } try { Write-SectionHeader "Certificate Tasks" Write-DarkGrayHost "Importing PFX certificate" PowerShell -ExecutionPolicy Bypass -File C:\OSDCloud\Scripts\Import-Certificate.ps1 | Out-Null Write-DarkGrayHost "Grabbing PFX certificate infos" # Newest valid certificate only, Connect-MgGraph fails with more than one $cert = Get-ChildItem -Path "Cert:\LocalMachine\My" | Where-Object { $_.Subject -Match "$CertificateSubjectName" -and $_.NotAfter -gt (Get-Date) } | Sort-Object NotAfter -Descending | Select-Object -First 1 if (-not $cert) { throw "No valid certificate '$CertificateSubjectName' found in LocalMachine\My (Import-Certificate.ps1 exit code: $LASTEXITCODE)." } Write-SectionHeader "Grabbing Autopilot parameters" $ProgramDataOSDeploy = "$env:ProgramData\OSDeploy" $JsonPath = "$ProgramDataOSDeploy\OSDeploy.AutopilotOOBE.json" $ImportAutopilotOOBE = Get-Content -Raw -Path $JsonPath -ErrorAction Stop | ConvertFrom-Json $Params = @{ Assign = $true Online = $true AddToGroup = $ImportAutopilotOOBE.AddToGroup AssignedComputerName = $ImportAutopilotOOBE.AssignedComputerName TenantID = $TenantID AppID = $AppID CleanupBeforeStaging = $CleanupBeforeStaging CleanupGraceSeconds = $CleanupGraceSeconds ImportTimeoutMinutes = $ImportTimeoutMinutes SyncTimeoutMinutes = $SyncTimeoutMinutes AssignTimeoutMinutes = $AssignTimeoutMinutes } Write-Host -ForegroundColor Yellow "ComputerName: $($ImportAutopilotOOBE.AssignedComputerName)" Write-Host -ForegroundColor Yellow "AddToGroup: $($ImportAutopilotOOBE.AddToGroup)" Write-Host -ForegroundColor Yellow "CleanupBeforeStaging: $CleanupBeforeStaging" Write-SectionHeader "Executing CustomWindowsAutopilotInfo" Start-Sleep -Seconds 3 CustomWindowsAutopilotInfo @Params if ($CtxStagingGroup) { Write-SectionHeader "Adding device to Entra ID group '$CtxStagingGroup'" try { $aadDeviceId = $Global:AutopilotStatus.AzureAdDeviceId if (-not $aadDeviceId) { # Import not complete in this run (e.g. device already registered), resolve via serial number $aadDeviceId = Get-AutopilotAzureAdDeviceId -SerialNumber $Global:AutopilotStatus.SerialNumber } $Global:AutopilotStatus.CtxStaging.Status = Add-DeviceToEntraGroup -GroupName $CtxStagingGroup -AzureAdDeviceId $aadDeviceId } catch { $Global:AutopilotStatus.CtxStaging.Status = 'failed' $Global:AutopilotStatus.CtxStaging.Error = Get-GraphErrorText $_ Write-Error "Adding device to '$CtxStagingGroup' failed: $(Get-GraphErrorText $_)" } } # Derive the overall result from the single steps, a run is only 'completed' if everything worked $s = $Global:AutopilotStatus $problems = @() if ($s.Import.Status -ne 'complete') { $problems += "Import: $($s.Import.Status) $($s.Import.ErrorName)" } if (-not $s.AzureAdDeviceId) { $problems += 'No synced Autopilot device in this run' } if (-not "$($s.ProfileAssignment)".StartsWith('assigned')) { $problems += "ProfileAssignment: $($s.ProfileAssignment)" } if ($s.AddToGroup.Status -eq 'failed') { $problems += "AddToGroup: $($s.AddToGroup.Error)" } if ($s.CtxStaging.Status -eq 'failed') { $problems += "CtxStaging: $($s.CtxStaging.Error)" } if ($problems.Count -eq 0) { $s.Result = 'completed' } else { $s.Result = 'completedWithErrors' $s.Errors += $problems Write-Warning "Autopilot Tasks completed with errors: $($problems -join '; ')" } } catch { $Global:AutopilotStatus.Result = 'failed' $Global:AutopilotStatus.Errors += Get-GraphErrorText $_ if ($Global:AutopilotStatus.Cleanup.Status -eq 'running') { $Global:AutopilotStatus.Cleanup.Status = 'failed' } Write-Error "Autopilot Tasks failed: $(Get-GraphErrorText $_)" } finally { # Every cleanup step on its own, so one failure does not skip the others Write-SectionHeader "Disconnect Graph API" try { Disconnect-MgGraph -ErrorAction Stop | Out-Null } catch { Write-DarkGrayHost "Graph not connected" } Write-SectionHeader "Cleanup scripts and certificates" Write-DarkGrayHost "Delete certificate including private key from local machine store" $cert = $null try { Get-ChildItem -Path "Cert:\LocalMachine\My" | Where-Object { $_.Subject -Match "$CertificateSubjectName" } | ForEach-Object { $thumbprint = $_.Thumbprint try { Remove-Item -Path "Cert:\LocalMachine\My\$thumbprint" -DeleteKey -Force -ErrorAction Stop } catch { Write-Warning "Unable to delete certificate $thumbprint with private key: $($_.Exception.Message)" Remove-Item -Path "Cert:\LocalMachine\My\$thumbprint" -Force -ErrorAction SilentlyContinue } } $leftover = @(Get-ChildItem -Path "Cert:\LocalMachine\My" | Where-Object { $_.Subject -Match "$CertificateSubjectName" }) $Global:AutopilotStatus.CertificateRemoved = ($leftover.Count -eq 0) if ($leftover.Count -gt 0) { Write-Warning "Certificate '$CertificateSubjectName' still present in LocalMachine\My ($($leftover.Count))" } } catch { $Global:AutopilotStatus.CertificateRemoved = $false Write-Warning "Certificate cleanup failed: $($_.Exception.Message)" } Write-DarkGrayHost "Remove Import-Certificate.ps1 script" try { if (Test-Path -Path $env:SystemDrive\OSDCloud\Scripts\Import-Certificate.ps1) { Remove-Item -Path $env:SystemDrive\OSDCloud\Scripts\Import-Certificate.ps1 -Force -ErrorAction Stop } } catch { Write-Warning "Unable to remove Import-Certificate.ps1: $($_.Exception.Message)" } try { $statusFile = Join-Path $LogDir "$RunStamp-AutopilotStatus.json" $Global:AutopilotStatus | ConvertTo-Json -Depth 6 | Out-File -FilePath $statusFile -Encoding UTF8 -Force Write-DarkGrayHost "Status report saved to $statusFile (Result: $($Global:AutopilotStatus.Result))" } catch { Write-Warning "Failed to write status report: $($_.Exception.Message)" } #Read-Host -Prompt "Autopilot Tasks completed. Press Enter to continue..." Stop-Transcript | Out-Null }