Add browser auth for headless runs

This commit is contained in:
2026-04-08 16:09:30 +02:00
parent c803a00df7
commit 239e3ec16e
12 changed files with 222 additions and 19 deletions

Binary file not shown.

View File

@@ -63,6 +63,16 @@ function Start-CoreApp
Invoke-ModuleFunction "Invoke-InitializeModule" 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) if(-not $global:SilentBatchFile)
{ {
Write-Log "SilentBatchFile must be specified" 3 Write-Log "SilentBatchFile must be specified" 3

View File

@@ -563,6 +563,14 @@ function Add-MSALPrereq
} }
$RequiredAssemblies.Add('System.Security.dll') $RequiredAssemblies.Add('System.Security.dll')
$RequiredAssemblies.Add('mscorlib.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) { if($PSVersionTable.PSVersion.Major -ge 7) {
$RequiredAssemblies.Add('System.Security.Cryptography.ProtectedData.dll') $RequiredAssemblies.Add('System.Security.Cryptography.ProtectedData.dll')
} }
@@ -574,6 +582,7 @@ function Add-MSALPrereq
} }
catch 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 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 $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 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 function Connect-MSALClientApp
@@ -931,7 +943,14 @@ function Get-MSALApp
[void]$appBuilder.WithAuthority($authority) [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.WithClientName("CloudAPIPowerShellManagement")
[void] $appBuilder.WithClientVersion($PSVersionTable.PSVersion) [void] $appBuilder.WithClientVersion($PSVersionTable.PSVersion)
@@ -952,7 +971,7 @@ function Get-MSALApp
$msalApp = $appBuilder.Build() $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")) [TokenCacheHelperEx]::EnableSerialization($msalApp.UserTokenCache, (Join-Path (Get-CloudApiDataFolder) "msalcahce.bin3"))
} }
@@ -961,6 +980,53 @@ function Get-MSALApp
return $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 function Get-MSALAppAuthority
{ {
try try
@@ -1001,20 +1067,33 @@ function Connect-MSALUser
if($global:hideUI -eq $true) 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 Connect-MSALClientApp $global:AzureAppId $global:TenantId -secret $global:ClientSecret
return
} }
elseif($global:AzureAppId -and $global:ClientCert -and $global:TenantId) elseif($global:AzureAppId -and $global:ClientCert -and $global:TenantId)
{ {
Connect-MSALClientApp $global:AzureAppId $global:TenantId -certificate $global:ClientCert Connect-MSALClientApp $global:AzureAppId $global:TenantId -certificate $global:ClientCert
return
} }
else 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 # No login during first time the app is started
@@ -1348,7 +1427,7 @@ function Connect-MSALUser
$app = $appBuilder.Build() $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")) [TokenCacheHelperEx]::EnableSerialization($app.UserTokenCache, (Join-Path (Get-CloudApiDataFolder) "msalcahce.bin3"))
} }

View File

@@ -1313,6 +1313,7 @@ function Show-GraphExportForm
function Invoke-InitSilentBatchJob function Invoke-InitSilentBatchJob
{ {
$global:MSALToken = $null $global:MSALToken = $null
$headlessAuthMode = ?? $global:HeadlessAuthMode "AppOnly"
if(-not $global:TenantId) if(-not $global:TenantId)
{ {
@@ -1320,12 +1321,15 @@ function Invoke-InitSilentBatchJob
return 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 # Get login info for silent job from settings
$global:AzureAppId = Get-SettingValue "GraphAzureAppId" -TenantID $global:TenantId $global:AzureAppId = Get-SettingValue "GraphAzureAppId" -TenantID $global:TenantId
$global:ClientSecret = Get-SettingValue "GraphAzureAppSecret" -TenantID $global:TenantId if($headlessAuthMode -eq "AppOnly")
$global:ClientCert = Get-SettingValue "GraphAzureAppCert" -TenantID $global:TenantId {
$global:ClientSecret = Get-SettingValue "GraphAzureAppSecret" -TenantID $global:TenantId
$global:ClientCert = Get-SettingValue "GraphAzureAppCert" -TenantID $global:TenantId
}
} }
if(-not $global:AzureAppId) if(-not $global:AzureAppId)
@@ -1334,7 +1338,7 @@ function Invoke-InitSilentBatchJob
return 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 Write-Log "Secret or Certificate must be specified. Either specify Secret/Certificate in Settings or Command Line" 3
return return

View File

@@ -37,13 +37,19 @@ function New-TemporaryBatchFile
function Test-AuthParameters function Test-AuthParameters
{ {
param( param(
[string]$AuthMode,
[string]$Secret, [string]$Secret,
[string]$Certificate [string]$Certificate
) )
if($AuthMode -eq "Browser")
{
return
}
if((-not $Secret) -and (-not $Certificate)) 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, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[psobject]$BatchConfig, [psobject]$BatchConfig,
@@ -68,7 +79,7 @@ function Invoke-IntuneHeadlessBatch
[string]$BatchFile [string]$BatchFile
) )
Test-AuthParameters -Secret $Secret -Certificate $Certificate Test-AuthParameters -AuthMode $AuthMode -Secret $Secret -Certificate $Certificate
$projectRoot = Get-IntuneManagementProjectRoot $projectRoot = Get-IntuneManagementProjectRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1" $runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
@@ -98,13 +109,19 @@ function Invoke-IntuneHeadlessBatch
TenantId = $TenantId TenantId = $TenantId
AppId = $AppId AppId = $AppId
SilentBatchFile = $BatchFile SilentBatchFile = $BatchFile
AuthMode = $AuthMode
} }
if($Secret) if($RedirectUri)
{
$invokeParams.RedirectUri = $RedirectUri
}
if($AuthMode -eq "AppOnly" -and $Secret)
{ {
$invokeParams.Secret = $Secret $invokeParams.Secret = $Secret
} }
else elseif($AuthMode -eq "AppOnly")
{ {
$invokeParams.Certificate = $Certificate $invokeParams.Certificate = $Certificate
} }
@@ -135,6 +152,11 @@ function Export-IntunePolicies
[string]$Certificate, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ExportPath, [string]$ExportPath,
@@ -167,6 +189,8 @@ function Export-IntunePolicies
-AppId $AppId ` -AppId $AppId `
-Secret $Secret ` -Secret $Secret `
-Certificate $Certificate ` -Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-BatchConfig $batchConfig ` -BatchConfig $batchConfig `
-SettingsFile $SettingsFile ` -SettingsFile $SettingsFile `
-BatchFile $BatchFile -BatchFile $BatchFile
@@ -186,6 +210,11 @@ function Import-IntunePolicies
[string]$Certificate, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ImportPath, [string]$ImportPath,
@@ -225,6 +254,8 @@ function Import-IntunePolicies
-AppId $AppId ` -AppId $AppId `
-Secret $Secret ` -Secret $Secret `
-Certificate $Certificate ` -Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-BatchConfig $batchConfig ` -BatchConfig $batchConfig `
-SettingsFile $SettingsFile ` -SettingsFile $SettingsFile `
-BatchFile $BatchFile -BatchFile $BatchFile
@@ -248,6 +279,11 @@ function Invoke-IntunePolicyAction
[string]$Certificate, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile, [string]$SettingsFile,
[string]$BatchFile, [string]$BatchFile,
@@ -282,6 +318,8 @@ function Invoke-IntunePolicyAction
-AppId $AppId ` -AppId $AppId `
-Secret $Secret ` -Secret $Secret `
-Certificate $Certificate ` -Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-ExportPath $ExportPath ` -ExportPath $ExportPath `
-SettingsFile $SettingsFile ` -SettingsFile $SettingsFile `
-BatchFile $BatchFile ` -BatchFile $BatchFile `
@@ -298,6 +336,8 @@ function Invoke-IntunePolicyAction
-AppId $AppId ` -AppId $AppId `
-Secret $Secret ` -Secret $Secret `
-Certificate $Certificate ` -Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-ImportPath $ImportPath ` -ImportPath $ImportPath `
-SettingsFile $SettingsFile ` -SettingsFile $SettingsFile `
-BatchFile $BatchFile ` -BatchFile $BatchFile `

View File

@@ -25,3 +25,12 @@ Export-IntunePolicies `
-Secret "<client-secret>" ` -Secret "<client-secret>" `
-ExportPath "/tmp/intune-export" -ExportPath "/tmp/intune-export"
``` ```
```powershell
Export-IntunePolicies `
-TenantId "<source-tenant-id>" `
-AppId "<public-client-app-id>" `
-AuthMode Browser `
-RedirectUri "http://localhost" `
-ExportPath "/tmp/intune-export"
```

View File

@@ -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 1. export policies from a source tenant
2. store the exported JSON and migration table 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 ## Entry points
@@ -18,8 +18,8 @@ This repository is now CLI-first. The old WPF application surface has been remov
## Runtime ## Runtime
* `pwsh` 7+ * `pwsh` 7+
* Microsoft Graph app registration with app-only access * Microsoft Graph app registration
* Client secret or certificate * App-only auth with client secret or certificate, or browser auth with a public client redirect URI
## Default object types ## Default object types
@@ -45,6 +45,16 @@ pwsh ./Scripts/Export-Policies.ps1 `
-IncludeAssignments -IncludeAssignments
``` ```
## Export with browser auth
```powershell
pwsh ./Scripts/Export-Policies.ps1 `
-TenantId "<source-tenant-id>" `
-AppId "<public-client-app-id>" `
-AuthMode Browser `
-ExportPath "/tmp/intune-export"
```
## Import ## Import
```powershell ```powershell
@@ -59,6 +69,16 @@ pwsh ./Scripts/Import-Policies.ps1 `
-ReplaceDependencyIds -ReplaceDependencyIds
``` ```
## Import with browser auth
```powershell
pwsh ./Scripts/Import-Policies.ps1 `
-TenantId "<target-tenant-id>" `
-AppId "<public-client-app-id>" `
-AuthMode Browser `
-ImportPath "/tmp/intune-export/SourceTenantName"
```
## Single entrypoint ## Single entrypoint
```powershell ```powershell
@@ -80,8 +100,19 @@ pwsh ./Start-HeadlessIntune.ps1 `
-ImportType alwaysImport -ImportType alwaysImport
``` ```
```powershell
pwsh ./Start-HeadlessIntune.ps1 `
-Action Export `
-TenantId "<source-tenant-id>" `
-AppId "<public-client-app-id>" `
-AuthMode Browser `
-RedirectUri "http://localhost" `
-ExportPath "/tmp/intune-export"
```
## Notes ## Notes
* Export writes a migration table used during cross-tenant import. * Export writes a migration table used during cross-tenant import.
* Import can translate dependency IDs and recreate missing assignment groups. * Import can translate dependency IDs and recreate missing assignment groups.
* This repo intentionally does not preserve the old Windows UI launch flow. * 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.

View File

@@ -17,6 +17,9 @@ function Initialize-IntuneManagementRuntime
[string]$AppId, [string]$AppId,
[string]$Secret, [string]$Secret,
[string]$Certificate, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$GraphEnvironment, [string]$GraphEnvironment,
[string]$GCCType [string]$GCCType
) )
@@ -30,6 +33,8 @@ function Initialize-IntuneManagementRuntime
$global:AzureAppId = $AppId $global:AzureAppId = $AppId
$global:ClientSecret = $Secret $global:ClientSecret = $Secret
$global:ClientCert = $Certificate $global:ClientCert = $Certificate
$global:HeadlessAuthMode = $AuthMode
$global:MSALRedirectUri = $RedirectUri
$global:UseGraphEnvironment = $GraphEnvironment $global:UseGraphEnvironment = $GraphEnvironment
$global:UseGCCType = $GCCType $global:UseGCCType = $GCCType
$global:UseJSonSettings = ($JSonSettings -eq $true) $global:UseJSonSettings = ($JSonSettings -eq $true)
@@ -68,6 +73,10 @@ function Initialize-IntuneManagementRuntime
{ {
Write-Host "Using Azure App Certificate" Write-Host "Using Azure App Certificate"
} }
elseif($global:HeadlessAuthMode -eq "Browser")
{
Write-Host "Using browser authentication"
}
else else
{ {
Write-Warning "Azure App Secret or Certificate is missing. Use -Secret <Secret> or -Certificate <Certificate>." Write-Warning "Azure App Secret or Certificate is missing. Use -Secret <Secret> or -Certificate <Certificate>."

View File

@@ -14,6 +14,11 @@ param(
[string]$Certificate, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ExportPath, [string]$ExportPath,

View File

@@ -14,6 +14,11 @@ param(
[string]$Certificate, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ImportPath, [string]$ImportPath,

View File

@@ -14,6 +14,11 @@ param(
[string]$Certificate, [string]$Certificate,
[ValidateSet("AppOnly","Browser")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile, [string]$SettingsFile,
[string]$BatchFile, [string]$BatchFile,
@@ -45,6 +50,7 @@ $invokeParams = @{
Action = $Action Action = $Action
TenantId = $TenantId TenantId = $TenantId
AppId = $AppId AppId = $AppId
AuthMode = $AuthMode
SettingsFile = $SettingsFile SettingsFile = $SettingsFile
BatchFile = $BatchFile BatchFile = $BatchFile
NameFilter = $NameFilter NameFilter = $NameFilter
@@ -71,4 +77,9 @@ elseif($Certificate)
$invokeParams.Certificate = $Certificate $invokeParams.Certificate = $Certificate
} }
if($RedirectUri)
{
$invokeParams.RedirectUri = $RedirectUri
}
Invoke-IntunePolicyAction @invokeParams Invoke-IntunePolicyAction @invokeParams