27/2/2024: Thanks Ryan P for pointing out that line 114 is missing the “-All” switch for the Get-MgDevice cmdlet. Previous versions of this script only fetched the first 100 devices.
It’s been a while since I’ve posted here! I wanted to share some of my thoughts (and a solution) about getting Azure AD joined (AADJ) devices connected to enterprise Wi-Fi networks.
I’ve recently been considering switching to a “modern” cloud-first infrastructure, ditching domain-joined devices and GPO for AADJ devices and Intune. To my surprise, most of the policies I configured in GPO are already available in Intune, so I had little difficulty migrating most of them to Intune. For some settings not quite natively supported by Intune yet, I resolved to push out PowerShell scripts to poke the registry.
Sitting in front of my Intune-managed Windows 11 22H2 pilot device (yes, I was also testing a migration from W10 to W11) joined to AAD, I was pretty happy with how it panned out. Install the OS, deploy a provisioning package, and everything falls into place automatically!
The only challenge that stood in my way was configuring Wi-Fi. At my church we use Microsoft’s Network Policy Server (NPS) to authenticate devices (via certificates) and users (usernames & passwords) to our Wi-Fi network, which works fairly well when everything lives in Active Directory (AD), but breaks down when we start venturing into the cloud. NPS relies on identities being in AD in order to authenticate them, but when it comes to a cloud-first approach where devices are AAD joined, there is no device identity in AD for NPS to find. Device identities only exist in AAD - if we discount device writeback, but that doesn’t generate an AD computer object that’s useful anyway.
Potential solutions
So what are my options? I immediately started researching (Google, haven’t signed up for ChatGPT just yet) and found many in a similar position. Willing to go for a cloud-first approach, but being held back by a lack of solutions for Wi-Fi authentication. A number of third-party ‘cloud RADIUS’ services often popped up, but being in a (cash-strapped) non-profit organisation, that’s a no-brainer.
I came across some more viable solutions - Working around NPS limitations for AADJ Windows devices , Microsoft NPS RADIUS for AADJ devices , AADx509Sync , AADJ-DummyObjects-Sync-x509 , AADJ-x509-Device-Sync . Unfortunately, it seems I’ve picked a bad time to take on this challenge, as KB5014754 has rendered the first 2 methods obsolete. These two methods simply created dummy computer objects in AD with the device ID of the AADJ device as the object name, then relied on NPS to match the device ID in the certificate to dummy object. KB5014754 has forced certificate mapping (not quite yet, but the writing is on the wall) where issued certificates now need to be mapped to an object in AD rather than relying on name mapping.
You can manually map certificates to AD objects using the “altSecurityIdentities” attribute, which is what the last 3 solutions do - in addition to creating the dummy AD computer object - and it works a treat. Sadly, I spent about 2-3 days trying to debug why the 3 solutions didn’t work for me, thinking it was an issue with the certificate mapping, only to find out I had misconfigured the certificate request policy in Intune. Don’t make the same mistake I did - pick “Enroll for Software KSP” for the key storage provider!
Now for my thoughts on the 3 solutions:
AADx509Sync: I didn’t particularly like how this depended on device writeback and group writeback (v2)
AADJ-DummyObjects-Sync-x509: Didn’t map groups from AAD
AADJ-x509-Device-Sync: Hardcoded list of groups - I like automation!
All 3 solutions also relied on mapping the certificate to the AD object after the certificate had been issued, which meant there was a period of time (albeit negligible depending on script run frequency) where the device would present its certificate to NPS, only to be rejected because the certificate hadn’t been mapped to the object yet.
What about mapping the certificate to the object at time of issue? That’s exactly what happens with domain-joined devices - ADCS enterprise CAs automatically adds a new extension in all certificates issued against online templates starting from the KB5014754 update. The new extension (OID 1.3.6.1.4.1.311.25.2) contains the SID of the AD object, allowing NPS to easily authenticate it without the need for weak name mapping. But this only works for online templates, which cannot be used when issuing certificates to AADJ devices via the Intune connector.
Configured a scheduled task on a DC to run a script to sync devices and groups from Azure AD to Active Directory as AD device & group objects (which NPS can locate)
Installed TameMyCerts policy module onto enterprise CA server, which injects the AD device object’s SID into the device certificate at time of issue (strong certificate mapping)
Use the Intune connector to request PKCS certificates from ADCS, with an Intune config profile installing the certificate onto the AADJ device
I have this script running every 15-30 minutes on a DC to sync AADJ devices and groups which contain AADJ devices down to Active Directory as AD computer and group objects. It uses device IDs (not device object ID, which is different) and group IDs from Azure AD as the names for the AD objects. I settled on using AAD group IDs instead of their display name as the name for AD group objects to allow for group name changes in AAD.
I put in a bunch of checks before actually deleting AD objects during the sync process, as ‘false deletions’ may be problematic (e.g. need to reissue device certificate as object SID has changed after recreation) There is also an option to revoke all device certificates, by finding all issued certificates that have a common name equal to the AAD device ID. Use with caution!
See these images (1 , 2 , 3 , 4 ) for task scheduler options if you’re running this on a schedule (taken from this Reddit thread )
# Azure AD Device Sync to Active Directory # Written by Keith Ng <[email protected]>, April 2023 # # Sources # AADx509Sync by tcppapi: https://github.com/tcppapi/AADx509Sync # AADJ-DummyObjects-Sync-x509 by saqib-s: https://github.com/saqib-s/AADJ-DummyObjects-Sync-x509 # AADJ-x509-Device-Sync by CodyRWhite: https://github.com/CodyRWhite/AADJ-x509-Device-Sync
# Azure AD app registration details # Requires Device.Read.All and Group.Read.All permissions (application, not delegated!) $tenantId = "" $clientId = "" $clientSecret = ""
# Name of the default group of all AD computer objects generated from sync # Similar to the "Domain Computers" group for domain-joined devices $defaultGroup = "Azure AD Devices"
# The organisational unit the devices and groups should sync to # Should be a dedicated OU used by this script only $orgUnit = "OU=Cloud Devices,DC=ad,DC=example,DC=com"
# Device/group deletion policies $removeDeletedDevices = $true# Set to $false if you don't want the script to delete computer objects from AD $removeDeletedGroups = $true# Set to $false if you don't want the script to delete group objects from AD $emptyDeviceProtection = $true# Leave as $true (recommended) to prevent the script from deleting computer objects when the device list from Azure AD is empty (could be due to error) $emptyGroupProtection = $true# Leave as $true (recommended) to prevent the script from deleting group objects when the group list from Azure AD is empty (could be due to error)
# Revoke device certificates on deletion from AD - account running this script must have correct permissions # When $true, will attempt to revoke any certificates (with reason 6 'certificate hold') that have device ID as CN # Only takes effect when $removeDeletedDevices = $true $revokeCertOnDelete = $false
# PowerShell module installation check # If set to $true, will install and update PowerShell modules as necessary # Setting this value to $false speeds up the script execution time as it skips the checks - but ensure you have the modules installed! $moduleChecks = $true
if ($revokeCertOnDelete) { $requiredModules = "ActiveDirectory", "Microsoft.Graph", "Microsoft.Graph.Groups", "Microsoft.Graph.Identity.DirectoryManagement", "PSPKI" } else { $requiredModules = "ActiveDirectory", "Microsoft.Graph", "Microsoft.Graph.Groups", "Microsoft.Graph.Identity.DirectoryManagement" } Write-Host"Importing required modules..." foreach ($modulein$requiredModules) { if ($moduleChecks) { # Check if installed version = online version, if not then update it (reinstall) [Version]$onlineVersion = (Find-Module-Name$module-ErrorAction SilentlyContinue).Version [Version]$installedVersion = (Get-Module-ListAvailable-Name$module | Sort-Object Version -Descending | Select-Object Version -First1).Version if ($onlineVersion-gt$installedVersion) { Write-Host"Installing module $($module)..." Install-Module-Name$Module-Force-AllowClobber } } # Import modules if (!(Get-Module-Name$module)) { if ($module-eq"Microsoft.Graph") { # Do not need to import this entire monstrosity continue } Write-Host"Importing module $($module)..." Import-Module-Name$module-Force } }
if (!(Get-ADOrganizationalUnit-Filter"distinguishedName -eq `"$($orgUnit)`"")) { Write-Host"`nThe specified org unit does not exist! Exiting script..."-ForegroundColor Red exit(1) }
Write-Host"`nFetching default group ID..." try { if (($defaultGroupObject = Get-ADGroup-Filter"Name -eq `"$($defaultGroup)`"")) { $defaultGroupObject | Move-ADObject-TargetPath$orgUnit# Ensure the default group is in our specified OU $defaultGroupId = (Get-ADGroup$defaultGroup-Properties@("primaryGroupToken")).primaryGroupToken } else { New-ADGroup-Path$orgUnit-Name$defaultGroup-GroupCategory Security -GroupScope Global $defaultGroupId = (Get-ADGroup$defaultGroup-Properties@("primaryGroupToken")).primaryGroupToken } } catch { Write-Host"`nSomething went wrong while fetching default group ID! Exiting script..."-ForegroundColor Red exit(1) }
# Connect to Microsoft Graph PowerShell Write-Host"`nConecting to Microsoft Graph..." try { Connect-MgGraph-AccessToken (ConvertTo-SecureString-String ((Invoke-RestMethod-Uri https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token -Method POST -Body@{Grant_Type="client_credentials";Scope="https://graph.microsoft.com/.default";Client_Id=$clientId;Client_Secret=$clientSecret}).access_token) -AsPlainText-Force) } catch { Write-Host"`nSomething went wrong while connecting to MS Graph! Exiting script..."-ForegroundColor Red exit(1) }
try { Get-MgDevice | Out-Null } catch { Write-Host"`nCannot fetch devices list from Azure AD - do you have the correct app permission set? Exiting script..."-ForegroundColor Red exit(1) }
try { Get-MgGroup | Out-Null } catch { Write-Host"`nCannot fetch groups list from Azure AD - do you have the correct app permission set? Exiting script..."-ForegroundColor Red exit(1) }
$aadDevices = @{} # To store device ID and name of all devices synced from AAD to AD $aadGroups = @{} # To store group ID and name of all groups synced from AAD to AD
# Pull all AAD joined devices Write-Host"`nFetching all Azure AD joined devices..." foreach ($deviceinGet-MgDevice-Filter"trustType eq 'AzureAD'"-All) { $guid = $device.DeviceId Write-Host"`nProcessing device $($guid)..."
if (!($aadDevices.ContainsKey($guid))) { #Write-Host "Adding device $($guid) to AAD devices dictionary..." $aadDevices.Add($guid, $device.DisplayName) }
Write-Host"Adding/updating AD object for $($guid)..." try { if (($adDevice = Get-ADComputer-Filter"Name -eq `"$($guid)`""-SearchBase$orgUnit)) { $adDevice | Set-ADComputer-Replace@{"servicePrincipalName"="host/$($guid)";"sAMAccountName"="$($sAMAccountName)";"description"="$($device.DisplayName)"} } else { $adDevice = New-ADComputer-Name$guid-ServicePrincipalNames"host/$($guid)"-SAMAccountName$sAMAccountName-Description"$($device.DisplayName)"-Path$orgUnit-AccountPassword$NULL-PasswordNotRequired$False-PassThru } $adDevice = Get-ADComputer-Filter"Name -eq `"$($guid)`""-SearchBase$orgUnit } catch { Write-Host"Something went wrong while adding/updating AD object for $($guid)"-ForegroundColor Red }
Write-Host"Changing AD primary group for $($guid)..." try { if (!((Get-ADGroupMember-Identity$defaultGroup | Select-Object-ExpandProperty Name) -contains$guid)) { Add-ADGroupMember-Identity$defaultGroup-Members$adDevice } Get-ADComputer$adDevice | Set-ADComputer-Replace@{primaryGroupID=$defaultGroupId} if ((Get-ADGroupMember-Identity"Domain Computers" | Select-Object-ExpandProperty Name) -contains$guid) { Remove-ADGroupMember-Identity"Domain Computers"-Members$adDevice-Confirm:$false } } catch { Write-Host"Something went wrong while changing AD primary group for $($guid)"-ForegroundColor Red }
$groups = @{} # To store group ID and name of all groups this device belongs to in AAD # Fetch all groups this device belongs to, then add it to the group Write-Host"Fetching all groups for device $($guid)..." foreach ($groupinGet-MgDeviceMemberOf-DeviceId$device.Id) { # Note $device.Id != $device.DeviceId, $device.Id is the device's object ID $groupId = $group.Id $groupName = (Get-MgGroup-GroupId$group.Id).DisplayName
if (!($aadGroups.ContainsKey($groupId))) { #Write-Host "Adding group $($groupId) to AAD groups dictionary..." $aadGroups.Add($groupId, (Get-MgGroup-GroupId$groupId).DisplayName) } if (!($groups.ContainsKey($groupId))) { #Write-Host "Adding group $($groupId) to groups dictionary for device $($guid)..." $groups.Add($groupId, (Get-MgGroup-GroupId$groupId).DisplayName) }
# Create group if doesn't exist already #Write-Host "Checking if group $($groupId) exists..." if (!($adGroup = Get-ADGroup-Filter"Name -eq `"$($groupId)`""-SearchBase$orgUnit)) { Write-Host"Creating group $($groupId)..." try { $adGroup = New-ADGroup-Path$orgUnit-Name$groupId-Description$groupName-GroupCategory Security -GroupScope Global } catch { Write-Host"Something went wrong while creating group $($groupId)"-ForegroundColor Red } }
Write-Host"Adding device $($guid) to group $($groupId)..." try { $adGroup = Get-ADGroup-Filter"Name -eq `"$($groupId)`""-SearchBase$orgUnit if (!(($adGroup | Get-ADGroupMember | Select-Object-ExpandProperty Name) -contains$guid)) { $adGroup | Add-ADGroupMember-Members$adDevice } } catch { Write-Host"Something went wrong while adding device $($guid) to group $($groupId)"-ForegroundColor Red } }
# Remove the device from any AD groups that it should no longer be in Write-Host"Removing device $($guid) from any existing AD groups it should no longer be part of..." foreach ($groupin (Get-ADPrincipalGroupMembership-Identity$adDevice)) { if ($group.Name -eq$defaultGroup) { # Don't remove device from its primary default group continue } if (!($group.Name -match"^([0-9a-fA-F]{8})(-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)([0-9a-fA-F]{11})([0-9a-fA-F])$")) { # Don't remove device from non-AAD groups continue } if (!($groups.ContainsKey($group.Name))) { Write-Host"Removing device $($guid) from group $($group.Name)..." try { $group | Remove-ADGroupMember-Members$adDevice-Confirm:$false } catch { Write-Host"Something went wrong while removing device $($guid) from group $($group.Name)"-ForegroundColor Red } } } }
# Remove AD objects that don't exist in Azure AD anymore # Checks and redundancies because we want to be as sure as possible before deleting
if (($aadDevices.Count -gt0) -or (!$emptyDeviceProtection)) { Write-Host"`nRemoving deleted devices in AAD from AD..." $adDevices = Get-ADComputer-Filter * -SearchBase$orgUnit | Where-Object Name -match"^([0-9a-fA-F]{8})(-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)([0-9a-fA-F]{11})([0-9a-fA-F])$" foreach ($devicein$adDevices) { # Delete the AD device if it doesn't exist in Azure AD if (!($aadDevices.ContainsKey($device.Name)) -and !(Get-MgDevice-DeviceId$device.Name -ErrorAction SilentlyContinue)) { Write-Host"Removing device $($device.Name)..." try { if ($removeDeletedDevices) { $device | Remove-ADComputer-Confirm:$false if ($revokeCertOnDelete) { # Revoke certificates where CN = device ID across all certification authorities # Using reason 6 (hold) to allow undo if necessary try { foreach ($certAuthorityin (Get-CertificationAuthority).ComputerName) { foreach ($certin (Get-IssuedRequest-CertificationAuthority$certAuthority-Property SerialNumber -Filter"CommonName -eq $($device.Name)")) { Write-Host"Revoking certificate $($cert.SerialNumber) for device $($device.Name)..." $cert | Revoke-Certificate-Reason"Hold" } } } catch { Write-Host"Something went wrong while revoking certificates for device $($device.Name)"-ForegroundColor Red } } } else { Write-Host"Device $($device.Name) has not been removed from AD due to device deletion policy in script"-ForegroundColor Yellow } } catch { Write-Host"Something went wrong while removing device $($device.Name)"-ForegroundColor Red } } } } else { Write-Host"`nSkipping AD device object deletion as AAD devices list is empty and protection policy is enabled in script"-ForegroundColor Yellow }
if (($aadGroups.Count -gt0) -or (!$emptyGroupProtection)) { Write-Host"`nRemoving deleted groups in AAD from AD..." $adGroups = Get-ADGroup-Filter * -SearchBase$orgUnit | Where-Object Name -match"^([0-9a-fA-F]{8})(-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)([0-9a-fA-F]{11})([0-9a-fA-F])$" foreach ($groupin$adGroups) { # Delete the AD group if it doesn't exist in Azure AD if (!($aadGroups.ContainsKey($group.Name)) -and !(Get-MgGroup-GroupId$group.Name -ErrorAction SilentlyContinue)) { Write-Host"Removing group $($group.Name)..." try { if ($removeDeletedGroups) { $group | Remove-ADGroup-Confirm:$false } else { Write-Host"Group $($group.Name) has not been removed from AD due to group deletion policy in script"-ForegroundColor Yellow } } catch { Write-Host"Something went wrong while removing group $($group.Name)"-ForegroundColor Red } } } } else { Write-Host"`nSkipping AD group object deletion as AAD group list is empty and protection policy is enabled in script"-ForegroundColor Yellow }
Write-Host"`nSync completed!"
For certificate mapping, ensure the TameMyCerts policy is installed on your CA server. Once installed, add a policy to your specified TameMyCerts policy directory. The XML file name must match the name of the certificate template you’re using to issue certificates to your AADJ devices (note template name, not its display name) I have included a regex pattern to validate the common name is in GUID format and the principal name specified in the SAN is in the format required for NPS (“host/{AAD device ID}”). Technically TameMyCert doesn’t need to validate these inputs as the Intune connector will provide these values and they should be correct - if you don’t like the regex, you can choose to allow anything in these fields with the "^.*$" pattern.
You can issue certificates however you prefer - I do so via an Intune PKCS profile. The only important thing is that the UPN value in the certificate must be in the “host/{AAD device ID}” format as shown here (which I have again swiped from the earlier Reddit thread) Also, pick the software KSP as your key service provider as I mentioned earlier - don’t waste time debugging like me, although I’m happy to be corrected if this does not turn out to be a requirement.
For your NPS and Wi-Fi profile configuration, use EAP-TLS (it’s called “Smart Card or other certificate” in NPS) as your authentication methods. Windows 11 22H2 has Credential Guard enabled by default , which may block authentication via PEAP. Additionally, keep in mind that apparently Windows 11 now uses TLS 1.3 for EAP authentication, so you may need to force TLS 1.2 - I did not encounter this issue, but my NPS server runs Windows Server 2022 which seems to support TLS 1.3 out of the box .
Final thoughts
Overall, I’m satisfied with my solution for connecting AADJ devices to NPS but the thought of it breaking when Microsoft chooses to force a new update similar to KB5014754 irks me a little - but then again, anything can break at any time, it’s Microsoft! However, I do think Microsoft has really left its cloud-first customers in the lurch when it comes to getting ‘cloud-joined devices’ hooked up to existing on-prem Wi-Fi networks.
On the one hand, I’m hopeful that Microsoft will come out with a cloud-based RADIUS solution to support AADJ devices, but on the other hand, I have a feeling that if this becomes a reality it will be launched as an Intune addon with additional costs (probably as part of the overpriced Intune Suite?)