diff --git a/Bin/Microsoft.Identity.Client.dll b/Bin/Microsoft.Identity.Client.dll index 82989b0..3fd807b 100644 Binary files a/Bin/Microsoft.Identity.Client.dll and b/Bin/Microsoft.Identity.Client.dll differ diff --git a/Bin/Microsoft.IdentityModel.Abstractions.dll b/Bin/Microsoft.IdentityModel.Abstractions.dll index 997724d..3c08a9c 100644 Binary files a/Bin/Microsoft.IdentityModel.Abstractions.dll and b/Bin/Microsoft.IdentityModel.Abstractions.dll differ diff --git a/Core.psm1 b/Core.psm1 index a679175..b9e12e2 100644 --- a/Core.psm1 +++ b/Core.psm1 @@ -63,6 +63,16 @@ function Start-CoreApp Invoke-ModuleFunction "Invoke-InitializeModule" + if($View) + { + $global:currentViewObject = $global:viewObjects | Where-Object { $_.ViewInfo.Id -eq $View } | Select-Object -First 1 + } + + if(-not $global:currentViewObject) + { + $global:currentViewObject = $global:viewObjects | Select-Object -First 1 + } + if(-not $global:SilentBatchFile) { Write-Log "SilentBatchFile must be specified" 3 diff --git a/Extensions/MSALAuthentication.psm1 b/Extensions/MSALAuthentication.psm1 index 6495549..4d92175 100644 --- a/Extensions/MSALAuthentication.psm1 +++ b/Extensions/MSALAuthentication.psm1 @@ -563,6 +563,14 @@ function Add-MSALPrereq } $RequiredAssemblies.Add('System.Security.dll') $RequiredAssemblies.Add('mscorlib.dll') + if($PSVersionTable.PSVersion.Major -ge 7) + { + $netStandardAssembly = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq "netstandard" } | Select-Object -First 1 + if($netStandardAssembly -and $netStandardAssembly.Location) + { + $RequiredAssemblies.Add($netStandardAssembly.Location) + } + } if($PSVersionTable.PSVersion.Major -ge 7) { $RequiredAssemblies.Add('System.Security.Cryptography.ProtectedData.dll') } @@ -574,6 +582,7 @@ function Add-MSALPrereq } catch { + $global:SkipTokenCacheHelperEx = $true Write-LogError "Failed to compile TokenCacheHelperEx. The access token will not be cached. Check write access to the CS folder and ASR policies" $_.Exception } } @@ -732,7 +741,10 @@ function Add-MSALPrereq_old $global:SkipTokenCacheHelperEx = $true Write-LogError "Failed to compile TokenCacheHelperEx. The access token will not be cached. Check write access to the CS folder and ASR policies" $_.Exception } - [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") + if(Test-IsWindowsPlatform) + { + [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") + } } function Connect-MSALClientApp @@ -931,7 +943,14 @@ function Get-MSALApp [void]$appBuilder.WithAuthority($authority) - if($appInfo.RedirectUri) { [void]$appBuilder.WithRedirectUri($appInfo.RedirectUri) } + if($appInfo.RedirectUri) + { + [void]$appBuilder.WithRedirectUri($appInfo.RedirectUri) + } + else + { + [void]$appBuilder.WithDefaultRedirectUri() + } [void] $appBuilder.WithClientName("CloudAPIPowerShellManagement") [void] $appBuilder.WithClientVersion($PSVersionTable.PSVersion) @@ -952,7 +971,7 @@ function Get-MSALApp $msalApp = $appBuilder.Build() - if($global:SkipTokenCacheHelperEx -ne $true -and (Get-SettingValue "CacheMSALToken")) + if($global:SkipTokenCacheHelperEx -ne $true -and ("TokenCacheHelperEx" -as [type]) -and (Get-SettingValue "CacheMSALToken")) { [TokenCacheHelperEx]::EnableSerialization($msalApp.UserTokenCache, (Join-Path (Get-CloudApiDataFolder) "msalcahce.bin3")) } @@ -961,6 +980,53 @@ function Get-MSALApp return $msalApp } +function Get-HeadlessPublicClientAppInfo +{ + $appObj = $null + + if($global:MSGraphGlobalApps) + { + $templateApp = $global:MSGraphGlobalApps | Where-Object ClientId -eq $global:AzureAppId | Select-Object -First 1 + if($templateApp) + { + $appObj = [PSCustomObject]@{ + Name = $templateApp.Name + ClientId = $templateApp.ClientId + RedirectUri = $templateApp.RedirectUri + Authority = $templateApp.Authority + TenantId = $global:TenantId + } + } + } + + if(-not $appObj) + { + $appObj = [PSCustomObject]@{ + Name = "Headless Browser Login" + ClientId = $global:AzureAppId + RedirectUri = $null + Authority = $null + TenantId = $global:TenantId + } + } + elseif($appObj.RedirectUri -and $appObj.RedirectUri -notmatch '^http://localhost(:\d+)?/?$') + { + $appObj.RedirectUri = $null + } + + if($global:MSALRedirectUri) + { + $appObj.RedirectUri = $global:MSALRedirectUri + } + + if($global:TenantId) + { + $appObj.TenantId = $global:TenantId + } + + $appObj +} + function Get-MSALAppAuthority { try @@ -1001,20 +1067,33 @@ function Connect-MSALUser if($global:hideUI -eq $true) { - if($global:AzureAppId -and $global:ClientSecret -and $global:TenantId) + $headlessAuthMode = ?? $global:HeadlessAuthMode "AppOnly" + + if($headlessAuthMode -eq "Browser") + { + if(-not $global:AzureAppId -or -not $global:TenantId) + { + Write-Log "Azure AppId and Tenant Id must be specified for browser auth" 3 + return + } + + $global:appObj = Get-HeadlessPublicClientAppInfo + } + elseif($global:AzureAppId -and $global:ClientSecret -and $global:TenantId) { Connect-MSALClientApp $global:AzureAppId $global:TenantId -secret $global:ClientSecret + return } elseif($global:AzureAppId -and $global:ClientCert -and $global:TenantId) { Connect-MSALClientApp $global:AzureAppId $global:TenantId -certificate $global:ClientCert + return } else { - Write-Log "Azure AppId, Tenant Id and Sercret/Cert must be specified for batch jobs" 3 + Write-Log "Azure AppId, Tenant Id and Secret/Cert must be specified for AppOnly auth, or use browser auth" 3 + return } - - return } # No login during first time the app is started @@ -1348,7 +1427,7 @@ function Connect-MSALUser $app = $appBuilder.Build() - if((Get-SettingValue "CacheMSALToken")) + if($global:SkipTokenCacheHelperEx -ne $true -and ("TokenCacheHelperEx" -as [type]) -and (Get-SettingValue "CacheMSALToken")) { [TokenCacheHelperEx]::EnableSerialization($app.UserTokenCache, (Join-Path (Get-CloudApiDataFolder) "msalcahce.bin3")) } diff --git a/Extensions/MSGraph.psm1 b/Extensions/MSGraph.psm1 index bb29fa7..e747882 100644 --- a/Extensions/MSGraph.psm1 +++ b/Extensions/MSGraph.psm1 @@ -1313,6 +1313,7 @@ function Show-GraphExportForm function Invoke-InitSilentBatchJob { $global:MSALToken = $null + $headlessAuthMode = ?? $global:HeadlessAuthMode "AppOnly" if(-not $global:TenantId) { @@ -1320,12 +1321,15 @@ function Invoke-InitSilentBatchJob return } - if(-not $global:AzureAppId -or (-not $global:ClientSecret -and -not $global:ClientCert)) + if(-not $global:AzureAppId -or ($headlessAuthMode -eq "AppOnly" -and (-not $global:ClientSecret -and -not $global:ClientCert))) { # Get login info for silent job from settings $global:AzureAppId = Get-SettingValue "GraphAzureAppId" -TenantID $global:TenantId - $global:ClientSecret = Get-SettingValue "GraphAzureAppSecret" -TenantID $global:TenantId - $global:ClientCert = Get-SettingValue "GraphAzureAppCert" -TenantID $global:TenantId + if($headlessAuthMode -eq "AppOnly") + { + $global:ClientSecret = Get-SettingValue "GraphAzureAppSecret" -TenantID $global:TenantId + $global:ClientCert = Get-SettingValue "GraphAzureAppCert" -TenantID $global:TenantId + } } if(-not $global:AzureAppId) @@ -1334,7 +1338,7 @@ function Invoke-InitSilentBatchJob return } - if(-not $global:ClientSecret -and -not $global:ClientCert) + if($headlessAuthMode -eq "AppOnly" -and -not $global:ClientSecret -and -not $global:ClientCert) { Write-Log "Secret or Certificate must be specified. Either specify Secret/Certificate in Settings or Command Line" 3 return diff --git a/Headless/IntuneManagement.Headless.psm1 b/Headless/IntuneManagement.Headless.psm1 index 07734b3..b28b5c4 100644 --- a/Headless/IntuneManagement.Headless.psm1 +++ b/Headless/IntuneManagement.Headless.psm1 @@ -37,13 +37,19 @@ function New-TemporaryBatchFile function Test-AuthParameters { param( + [string]$AuthMode, [string]$Secret, [string]$Certificate ) + if($AuthMode -eq "Browser") + { + return + } + if((-not $Secret) -and (-not $Certificate)) { - throw "Specify -Secret or -Certificate." + throw "Specify -Secret or -Certificate for AppOnly auth, or use -AuthMode Browser." } } @@ -60,6 +66,11 @@ function Invoke-IntuneHeadlessBatch [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + [Parameter(Mandatory = $true)] [psobject]$BatchConfig, @@ -68,7 +79,7 @@ function Invoke-IntuneHeadlessBatch [string]$BatchFile ) - Test-AuthParameters -Secret $Secret -Certificate $Certificate + Test-AuthParameters -AuthMode $AuthMode -Secret $Secret -Certificate $Certificate $projectRoot = Get-IntuneManagementProjectRoot $runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1" @@ -98,13 +109,19 @@ function Invoke-IntuneHeadlessBatch TenantId = $TenantId AppId = $AppId SilentBatchFile = $BatchFile + AuthMode = $AuthMode } - if($Secret) + if($RedirectUri) + { + $invokeParams.RedirectUri = $RedirectUri + } + + if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret } - else + elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate } @@ -135,6 +152,11 @@ function Export-IntunePolicies [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + [Parameter(Mandatory = $true)] [string]$ExportPath, @@ -167,6 +189,8 @@ function Export-IntunePolicies -AppId $AppId ` -Secret $Secret ` -Certificate $Certificate ` + -AuthMode $AuthMode ` + -RedirectUri $RedirectUri ` -BatchConfig $batchConfig ` -SettingsFile $SettingsFile ` -BatchFile $BatchFile @@ -186,6 +210,11 @@ function Import-IntunePolicies [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + [Parameter(Mandatory = $true)] [string]$ImportPath, @@ -225,6 +254,8 @@ function Import-IntunePolicies -AppId $AppId ` -Secret $Secret ` -Certificate $Certificate ` + -AuthMode $AuthMode ` + -RedirectUri $RedirectUri ` -BatchConfig $batchConfig ` -SettingsFile $SettingsFile ` -BatchFile $BatchFile @@ -248,6 +279,11 @@ function Invoke-IntunePolicyAction [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + [string]$SettingsFile, [string]$BatchFile, @@ -282,6 +318,8 @@ function Invoke-IntunePolicyAction -AppId $AppId ` -Secret $Secret ` -Certificate $Certificate ` + -AuthMode $AuthMode ` + -RedirectUri $RedirectUri ` -ExportPath $ExportPath ` -SettingsFile $SettingsFile ` -BatchFile $BatchFile ` @@ -298,6 +336,8 @@ function Invoke-IntunePolicyAction -AppId $AppId ` -Secret $Secret ` -Certificate $Certificate ` + -AuthMode $AuthMode ` + -RedirectUri $RedirectUri ` -ImportPath $ImportPath ` -SettingsFile $SettingsFile ` -BatchFile $BatchFile ` diff --git a/Headless/README.md b/Headless/README.md index ebf8246..15b9c11 100644 --- a/Headless/README.md +++ b/Headless/README.md @@ -25,3 +25,12 @@ Export-IntunePolicies ` -Secret "" ` -ExportPath "/tmp/intune-export" ``` + +```powershell +Export-IntunePolicies ` + -TenantId "" ` + -AppId "" ` + -AuthMode Browser ` + -RedirectUri "http://localhost" ` + -ExportPath "/tmp/intune-export" +``` diff --git a/README.md b/README.md index 078af22..9ffd651 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This repository is now CLI-first. The old WPF application surface has been remov 1. export policies from a source tenant 2. store the exported JSON and migration table -3. import into a target tenant with app-only authentication +3. import into a target tenant with app-only or browser authentication ## Entry points @@ -18,8 +18,8 @@ This repository is now CLI-first. The old WPF application surface has been remov ## Runtime * `pwsh` 7+ -* Microsoft Graph app registration with app-only access -* Client secret or certificate +* Microsoft Graph app registration +* App-only auth with client secret or certificate, or browser auth with a public client redirect URI ## Default object types @@ -45,6 +45,16 @@ pwsh ./Scripts/Export-Policies.ps1 ` -IncludeAssignments ``` +## Export with browser auth + +```powershell +pwsh ./Scripts/Export-Policies.ps1 ` + -TenantId "" ` + -AppId "" ` + -AuthMode Browser ` + -ExportPath "/tmp/intune-export" +``` + ## Import ```powershell @@ -59,6 +69,16 @@ pwsh ./Scripts/Import-Policies.ps1 ` -ReplaceDependencyIds ``` +## Import with browser auth + +```powershell +pwsh ./Scripts/Import-Policies.ps1 ` + -TenantId "" ` + -AppId "" ` + -AuthMode Browser ` + -ImportPath "/tmp/intune-export/SourceTenantName" +``` + ## Single entrypoint ```powershell @@ -80,8 +100,19 @@ pwsh ./Start-HeadlessIntune.ps1 ` -ImportType alwaysImport ``` +```powershell +pwsh ./Start-HeadlessIntune.ps1 ` + -Action Export ` + -TenantId "" ` + -AppId "" ` + -AuthMode Browser ` + -RedirectUri "http://localhost" ` + -ExportPath "/tmp/intune-export" +``` + ## Notes * Export writes a migration table used during cross-tenant import. * Import can translate dependency IDs and recreate missing assignment groups. * This repo intentionally does not preserve the old Windows UI launch flow. +* Browser auth uses the system browser and a loopback redirect. If your app registration does not allow loopback redirects, pass `-RedirectUri "http://localhost"` and configure the same redirect URI in Entra ID. diff --git a/Runtime/IntuneManagement.Runtime.psm1 b/Runtime/IntuneManagement.Runtime.psm1 index 8adc577..21c98c1 100644 --- a/Runtime/IntuneManagement.Runtime.psm1 +++ b/Runtime/IntuneManagement.Runtime.psm1 @@ -17,6 +17,9 @@ function Initialize-IntuneManagementRuntime [string]$AppId, [string]$Secret, [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + [string]$RedirectUri, [string]$GraphEnvironment, [string]$GCCType ) @@ -30,6 +33,8 @@ function Initialize-IntuneManagementRuntime $global:AzureAppId = $AppId $global:ClientSecret = $Secret $global:ClientCert = $Certificate + $global:HeadlessAuthMode = $AuthMode + $global:MSALRedirectUri = $RedirectUri $global:UseGraphEnvironment = $GraphEnvironment $global:UseGCCType = $GCCType $global:UseJSonSettings = ($JSonSettings -eq $true) @@ -68,6 +73,10 @@ function Initialize-IntuneManagementRuntime { Write-Host "Using Azure App Certificate" } + elseif($global:HeadlessAuthMode -eq "Browser") + { + Write-Host "Using browser authentication" + } else { Write-Warning "Azure App Secret or Certificate is missing. Use -Secret or -Certificate ." diff --git a/Scripts/Export-Policies.ps1 b/Scripts/Export-Policies.ps1 index f7de805..c6d97e5 100644 --- a/Scripts/Export-Policies.ps1 +++ b/Scripts/Export-Policies.ps1 @@ -14,6 +14,11 @@ param( [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + [Parameter(Mandatory = $true)] [string]$ExportPath, diff --git a/Scripts/Import-Policies.ps1 b/Scripts/Import-Policies.ps1 index dcefe28..5e9eece 100644 --- a/Scripts/Import-Policies.ps1 +++ b/Scripts/Import-Policies.ps1 @@ -14,6 +14,11 @@ param( [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + [Parameter(Mandatory = $true)] [string]$ImportPath, diff --git a/Start-HeadlessIntune.ps1 b/Start-HeadlessIntune.ps1 index 327e2a6..b6c96ab 100644 --- a/Start-HeadlessIntune.ps1 +++ b/Start-HeadlessIntune.ps1 @@ -14,6 +14,11 @@ param( [string]$Certificate, + [ValidateSet("AppOnly","Browser")] + [string]$AuthMode = "AppOnly", + + [string]$RedirectUri, + [string]$SettingsFile, [string]$BatchFile, @@ -45,6 +50,7 @@ $invokeParams = @{ Action = $Action TenantId = $TenantId AppId = $AppId + AuthMode = $AuthMode SettingsFile = $SettingsFile BatchFile = $BatchFile NameFilter = $NameFilter @@ -71,4 +77,9 @@ elseif($Certificate) $invokeParams.Certificate = $Certificate } +if($RedirectUri) +{ + $invokeParams.RedirectUri = $RedirectUri +} + Invoke-IntunePolicyAction @invokeParams