Atliso-AppleTokenAudit.ps1
|
<#PSScriptInfo .VERSION 1.0.0 .GUID a912a0ea-50bc-4989-a8ef-6842df946786 .AUTHOR Atliso .COMPANYNAME Atliso .COPYRIGHT (c) 2026 Atliso. MIT License. .TAGS Intune Apple APNs ADE VPP MDM Graph Audit ReadOnly .LICENSEURI https://github.com/Atliso-Dev/apple-token-audit/blob/main/LICENSE .PROJECTURI https://atliso.com/blog/intune-apple-mdm-push-certificate-expired .REQUIREDMODULES Microsoft.Graph.Authentication .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES First release. #> <# .SYNOPSIS Atliso Apple Token Audit. Read-only. .DESCRIPTION Lists the Apple MDM push certificate, every ADE enrolment token and every Apps and Books (VPP) token in Intune, with expiry date, days left and the Apple ID behind each one. Exports a CSV. Changes nothing in your tenant, so there is no WhatIf switch. https://atliso.com/blog/intune-apple-mdm-push-certificate-expired .PARAMETER TenantId Optional. A tenant ID or domain to sign in to, for MSPs running it against customer tenants. .NOTES Requires Microsoft.Graph.Authentication. Scopes: DeviceManagementServiceConfig.Read.All, DeviceManagementApps.Read.All (+ DeviceManagementManagedDevices.Read.All when $CountAppleDevices is on) Exit 0 = nothing expired, invalid or inside $WarnDays. Exit 1 = something needs renewing. #> [CmdletBinding()] param( [string]$TenantId ) # ===== CONFIG ===== $OutputPath = Join-Path $env:USERPROFILE 'Desktop\Atliso-AppleTokens.csv' $WarnDays = 60 # flag anything that expires within this many days $CriticalDays = 14 $CountAppleDevices = $true # count the Apple devices that depend on the push certificate $LogPath = Join-Path $env:LOCALAPPDATA 'Atliso\AppleTokenAudit.log' # ================== $ErrorActionPreference = 'Stop' $PersonalDomains = 'gmail.com','googlemail.com','icloud.com','me.com','mac.com','outlook.com','hotmail.com','live.com','yahoo.com' $GraceDays = 30 function Write-Log([string]$Message) { try { $dir = Split-Path -Path $LogPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } Add-Content -Path $LogPath -Value ("{0} {1}" -f (Get-Date -Format s), $Message) } catch { } } # HTTP status from a Graph error. PowerShell 7 and Windows PowerShell 5.1 throw different exception shapes. function Get-HttpStatus($ErrorRecord) { $ex = $ErrorRecord.Exception try { if ($ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } } catch { } if ([string]$ex.Message -match '\b(404|429)\b') { return [int]$Matches[1] } return $null } function Invoke-Graph([string]$Uri) { for ($try = 1; $try -le 5; $try++) { try { return Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject } catch { $status = Get-HttpStatus $_ if ($status -eq 404) { return $null } if ($status -eq 429 -and $try -lt 5) { $wait = 10 try { $ra = $_.Exception.Response.Headers.RetryAfter.Delta; if ($ra) { $wait = [int][math]::Ceiling($ra.TotalSeconds) } } catch { } Write-Log "429 on $Uri, waiting $wait s" Start-Sleep -Seconds $wait continue } throw } } } function Get-GraphList([string]$Uri) { $items = New-Object System.Collections.Generic.List[object] while ($Uri) { $page = Invoke-Graph $Uri if (-not $page) { break } foreach ($v in @($page.value)) { if ($null -ne $v) { $items.Add($v) } } $Uri = $page.'@odata.nextLink' } return ,$items } # Graph returns 0001-01-01 for dates that were never set. Treat anything before 2000 as no date. function ConvertTo-UtcDate($Value) { if (-not $Value) { return $null } if ($Value -is [datetime]) { $d = $Value.ToUniversalTime() } else { $d = ([datetimeoffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture)).UtcDateTime } if ($d.Year -lt 2000) { return $null } return $d } $now = (Get-Date).ToUniversalTime() $rows = New-Object System.Collections.Generic.List[object] function Add-Row { param($Type, $Name, $AppleId, $Expires, $RenewIn, [string[]]$Notes = @(), $ForceStatus) $days = $null if ($Expires) { $days = [int][math]::Ceiling(($Expires - $now).TotalDays) } if ($ForceStatus) { $status = $ForceStatus } elseif ($null -eq $days) { $status = 'Unknown'; $Notes += 'No expiry date reported.' } elseif ($Expires -lt $now) { $status = 'Expired' } elseif ($days -le $CriticalDays) { $status = 'Critical' } elseif ($days -le $WarnDays) { $status = 'Warning' } else { $status = 'OK' } if ($AppleId -and ([string]$AppleId -match '@([^@\s]+)$') -and ($PersonalDomains -contains $Matches[1].ToLower())) { if ($Type -eq 'Push certificate') { $Notes += "Personal Apple ID. You can't change it without re-enrolling every Apple device, so make sure someone can still sign in to it." } else { $Notes += 'Personal Apple ID. Move this token to an organisation Apple ID at the next renewal.' } } $expiresText = '' if ($Expires) { $expiresText = $Expires.ToString('yyyy-MM-dd') } $rows.Add([pscustomobject]@{ Type = $Type Name = $Name AppleId = $AppleId Expires = $expiresText DaysLeft = $days Status = $status RenewIn = $RenewIn Note = ($Notes -join ' ') }) } Write-Log 'Run started' $scopes = @('DeviceManagementServiceConfig.Read.All', 'DeviceManagementApps.Read.All') if ($CountAppleDevices) { $scopes += 'DeviceManagementManagedDevices.Read.All' } $connect = @{ Scopes = $scopes; NoWelcome = $true } if ($TenantId) { $connect.TenantId = $TenantId } Connect-MgGraph @connect # 1. Apple MDM push certificate. A 404 means none is configured. $push = Invoke-Graph 'https://graph.microsoft.com/v1.0/deviceManagement/applePushNotificationCertificate' $pushExpires = $null if ($push) { $pushExpires = ConvertTo-UtcDate $push.expirationDateTime } if ($push -and $pushExpires) { $notes = @() if ($pushExpires -lt $now) { $daysPast = [int][math]::Floor(($now - $pushExpires).TotalDays) if ($daysPast -le $GraceDays) { $notes += "Inside Microsoft's 30-day grace period. Renew now with the same Apple ID." } else { $notes += 'Past the 30-day grace period. Try Renew with the same Apple ID before creating a new certificate.' } } Add-Row -Type 'Push certificate' -Name 'Apple MDM push certificate' -AppleId $push.appleIdentifier -Expires $pushExpires ` -RenewIn 'Intune > Devices > Device onboarding > Enrollment > Apple > Apple MDM Push Certificate' -Notes $notes } else { Write-Host 'No Apple MDM push certificate configured. Apple devices are not managed in this tenant.' -ForegroundColor DarkGray } # 2. ADE enrolment program tokens. Graph only has these on beta, so a failure here doesn't stop the run. try { $ade = Get-GraphList 'https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings?$select=id,tokenName,appleIdentifier,tokenExpirationDateTime,tokenType' foreach ($t in $ade) { $name = $t.tokenName if (-not $name) { $name = 'Enrolment program token' } Add-Row -Type 'ADE token' -Name $name -AppleId $t.appleIdentifier -Expires (ConvertTo-UtcDate $t.tokenExpirationDateTime) ` -RenewIn 'Apple Business Manager > Preferences > MDM server > Download Token, then Intune > Enrollment program tokens > Renew token' } } catch { Write-Log "ADE read failed: $($_.Exception.Message)" Write-Host "Could not read ADE tokens (beta endpoint): $($_.Exception.Message)" -ForegroundColor Yellow } # 3. Apps and Books (VPP) tokens $vpp = Get-GraphList 'https://graph.microsoft.com/v1.0/deviceAppManagement/vppTokens?$select=id,organizationName,appleId,expirationDateTime,state,lastSyncStatus' foreach ($t in $vpp) { $name = 'Apps and Books token' if ($t.organizationName) { $name = "Apps and Books: $($t.organizationName)" } $force = $null; $notes = @() if (@('expired', 'invalid', 'assignedToExternalMDM') -contains [string]$t.state) { $force = 'Invalid'; $notes += "Intune reports state '$($t.state)'." } if ([string]$t.lastSyncStatus -eq 'failed') { $notes += 'Last sync with Apple failed.' } Add-Row -Type 'VPP token' -Name $name -AppleId $t.appleId -Expires (ConvertTo-UtcDate $t.expirationDateTime) ` -RenewIn 'Apple Business Manager > Preferences > Payments and Billing > Apps and Books > Content Tokens, then Intune > Tenant administration > Connectors and tokens > Apple VPP tokens' ` -Notes $notes -ForceStatus $force } # 4. Optional: how many Apple devices depend on the push certificate $appleCount = $null if ($CountAppleDevices -and $pushExpires) { try { $devices = Get-GraphList 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$select=id,operatingSystem' $appleCount = @($devices | Where-Object { @('iOS', 'iPadOS', 'macOS') -contains $_.operatingSystem }).Count } catch { Write-Log "Device count failed: $($_.Exception.Message)" } } # Output if ($rows.Count -gt 0) { $outDir = Split-Path -Path $OutputPath -Parent if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } $rows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 $rows | Sort-Object @{ Expression = { if ($null -eq $_.DaysLeft) { [int]::MaxValue } else { $_.DaysLeft } } } | Format-Table Type, Name, AppleId, Expires, DaysLeft, Status -AutoSize | Out-Host } foreach ($r in $rows | Where-Object { $_.Note }) { Write-Host ("{0} ({1}): {2}" -f $r.Name, $r.Status, $r.Note) -ForegroundColor Yellow } if ($null -ne $appleCount) { Write-Host ("{0} Apple devices depend on the push certificate." -f $appleCount) } $bad = @($rows | Where-Object { @('Expired', 'Critical', 'Warning', 'Invalid') -contains $_.Status }) if ($rows.Count -gt 0) { Write-Host ("{0} items checked, {1} need attention. CSV: {2}" -f $rows.Count, $bad.Count, $OutputPath) } else { Write-Host 'No Apple certificates or tokens found in Intune. Nothing to export.' } Write-Host 'Check these every night instead of once a year: https://atliso.com/pro?ref=apple-token-audit' -ForegroundColor Cyan Write-Log ("Run finished: {0} items, {1} need attention" -f $rows.Count, $bad.Count) if ($bad.Count -gt 0) { exit 1 } else { exit 0 } |