Connecting AADJ devices to Wi-Fi with NPS RADIUS

Keith Ng

CHANGELOG

  • 7/7/2023: Microsoft Graph PowerShell v2 broke my script by changing the access token parameter to SecureString when using ‘Connect-MgGraph’ in line 89. The script has now been updated for 2.0.0
  • 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:

  1. AADx509Sync: I didn’t particularly like how this depended on device writeback and group writeback (v2)
  2. AADJ-DummyObjects-Sync-x509: Didn’t map groups from AAD
  3. 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.

Luckily there is an ADCS policy module called TameMyCerts developed by Uwe Gradenegger which can perform directory services mapping with the SID certificate extension when the certificate is issued.
With this in mind, I set out to make my own solution.

My solution

  • 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

Requirements:

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 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# 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 ($module in $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 -First 1).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 ($device in Get-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)
}

$guid -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])$" | Out-Null
$sAMAccountName = "$($matches[1])"+"$($matches[3])"+"$"

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 ($group in Get-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 ($group in (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 -gt 0) -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 ($device in $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 ($certAuthority in (Get-CertificationAuthority).ComputerName) {
foreach ($cert in (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 -gt 0) -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 ($group in $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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<CertificateRequestPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Subject>
<SubjectRule>
<Field>commonName</Field>
<Mandatory>true</Mandatory>
<MaxOccurrences>1</MaxOccurrences>
<Patterns>
<Pattern>
<Expression>^([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])$</Expression>
</Pattern>
</Patterns>
</SubjectRule>
</Subject>
<SubjectAlternativeName>
<SubjectRule>
<Field>userPrincipalName</Field>
<Mandatory>true</Mandatory>
<MaxOccurrences>1</MaxOccurrences>
<Patterns>
<Pattern>
<Expression>^(?i)(host\/)([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])$</Expression>
</Pattern>
</Patterns>
</SubjectRule>
</SubjectAlternativeName>
<DirectoryServicesMapping>
<CertificateAttribute>commonName</CertificateAttribute>
<DirectoryServicesAttribute>cn</DirectoryServicesAttribute>
<ObjectCategory>computer</ObjectCategory>
</DirectoryServicesMapping>
<SecurityIdentifierExtension>Add</SecurityIdentifierExtension>
</CertificateRequestPolicy>

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?)

 Comments
On this page
Connecting AADJ devices to Wi-Fi with NPS RADIUS