mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
242 lines
10 KiB
PowerShell
242 lines
10 KiB
PowerShell
# Browserless-login support.
|
|
#
|
|
# On Linux/macOS (and Windows with "Use system browser") MSAL drives interactive
|
|
# sign-in through the system browser + a loopback redirect. On a machine with no
|
|
# usable default browser the redirect never arrives and the interactive poll loop
|
|
# waits out its whole timeout with the UI locked. This file provides the pieces
|
|
# that let the login flow (a) detect that situation up-front, (b) fail fast when a
|
|
# browser launch actually fails, and (c) fall back to device-code login instead.
|
|
#
|
|
# See docs/superpowers/specs/2026-09-05-browserless-login-device-code-fallback-design.md
|
|
|
|
# Marker embedded in every "could not open a browser" failure so the caller can
|
|
# tell a browser-launch failure apart from a normal auth error and fall back to
|
|
# device code.
|
|
$script:IMNoBrowserMarker = "IM_NO_BROWSER"
|
|
|
|
# Thin, mockable wrapper around Get-Command so the browser probe can be unit
|
|
# tested without shelling out to the real PATH.
|
|
function Test-CommandExists {
|
|
param([string]$Name)
|
|
if(-not $Name) { return $false }
|
|
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
|
|
}
|
|
|
|
# Wraps `xdg-settings get default-web-browser`. Returns the handler (e.g.
|
|
# 'firefox.desktop') or $null. Separated out so tests can mock it.
|
|
function Get-XdgDefaultWebBrowser {
|
|
if(-not (Test-CommandExists "xdg-settings")) { return $null }
|
|
try {
|
|
$out = & xdg-settings get default-web-browser 2>$null
|
|
if($out) {
|
|
$line = ($out | Select-Object -First 1)
|
|
if($line) { return ([string]$line).Trim() }
|
|
}
|
|
}
|
|
catch { }
|
|
return $null
|
|
}
|
|
|
|
# True when interactive browser sign-in has a realistic chance of working.
|
|
# Conservative by design: only returns $false when we are confident there is no
|
|
# browser, so a false negative can't send a browser-capable machine down the
|
|
# device-code path. The fast-fail launcher (IMMsalBrowserLauncher) covers the
|
|
# opposite case (a browser that is configured but broken).
|
|
function Test-InteractiveBrowserAvailable {
|
|
# Windows: WAM / embedded WebView / Edge is always available.
|
|
if($script:IsWindowsOS) { return $true }
|
|
# macOS: LaunchServices always resolves a default handler.
|
|
if($IsMacOS) { return $true }
|
|
|
|
# Linux: probe. 1) $env:BROWSER pointing at a resolvable command.
|
|
if($env:BROWSER) {
|
|
$cmd = (($env:BROWSER -split ':')[0] -split '\s+')[0]
|
|
if($cmd -and (Test-CommandExists $cmd)) { return $true }
|
|
}
|
|
|
|
# 2) A registered xdg default web browser.
|
|
if(Get-XdgDefaultWebBrowser) { return $true }
|
|
|
|
# 3) A known browser binary on PATH. NB: xdg-open is deliberately excluded -
|
|
# it ships on headless boxes and is not itself a browser.
|
|
foreach($b in @(
|
|
"firefox","firefox-esr","google-chrome","google-chrome-stable","chrome",
|
|
"chromium","chromium-browser","brave-browser","microsoft-edge",
|
|
"microsoft-edge-stable","opera","vivaldi","vivaldi-stable","epiphany",
|
|
"konqueror","falkon","midori")) {
|
|
if(Test-CommandExists $b) { return $true }
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
# True when a failure (exception or string) carries the no-browser marker, i.e.
|
|
# the interactive browser launch could not open a browser at all.
|
|
function Test-IMNoBrowserFailure {
|
|
param($Failure)
|
|
if($null -eq $Failure) { return $false }
|
|
|
|
$text = $null
|
|
if($Failure -is [System.Exception]) {
|
|
$text = $Failure.Message
|
|
$inner = $Failure.InnerException
|
|
$depth = 0
|
|
while($inner -and $depth -lt 10) {
|
|
$text = "$text $($inner.Message)"
|
|
$inner = $inner.InnerException
|
|
$depth++
|
|
}
|
|
}
|
|
else {
|
|
$text = [string]$Failure
|
|
}
|
|
|
|
return ($text -match $script:IMNoBrowserMarker)
|
|
}
|
|
|
|
# Compile the compiled-C# browser launcher used for MSAL's
|
|
# SystemWebViewOptions.OpenBrowserFunc. It MUST be compiled C# (not a ScriptBlock
|
|
# cast to a delegate): MSAL invokes OpenBrowserFunc on an internal thread with no
|
|
# PowerShell Runspace, where a ScriptBlock delegate throws "no Runspace
|
|
# available" - the same constraint documented for the device-code callback.
|
|
#
|
|
# The launcher owns the browser launch so it can DETECT failure: if the browser
|
|
# command can't start, or exits non-zero quickly (xdg-open returns 3 = "no
|
|
# handler", 4 = "action failed"), it throws with the IM_NO_BROWSER marker, which
|
|
# faults the MSAL task in ~1s instead of hanging the whole interactive timeout.
|
|
function Initialize-IMBrowserLauncher {
|
|
if("IMMsalBrowserLauncher" -as [type]) { return }
|
|
|
|
$refAssemblies = @(
|
|
[System.Object].Assembly.Location,
|
|
[System.Diagnostics.Process].Assembly.Location,
|
|
[System.Threading.Tasks.Task].Assembly.Location,
|
|
[System.Uri].Assembly.Location,
|
|
# Process derives from System.ComponentModel.Component (System.ComponentModel.Primitives
|
|
# on .NET Core); without this ref the compile fails with CS0012 on Process.Start.
|
|
[System.ComponentModel.Component].Assembly.Location,
|
|
[System.ComponentModel.Win32Exception].Assembly.Location
|
|
) | Where-Object { $_ } | Select-Object -Unique
|
|
|
|
# -CompilerOptions is PS7-only: Windows PowerShell 5.1 compiles through CodeDom
|
|
# and takes -CompilerParameters instead, so passing it there fails the whole
|
|
# call ("a parameter cannot be found"). The type then never compiles, the
|
|
# "already a type?" guard above never becomes true, and every call retried and
|
|
# logged the same error - on 5.1 the fast-fail browser detection was simply
|
|
# absent. The option only silences warnings 1701/1702, so 5.1 goes without it.
|
|
$addTypeParams = @{
|
|
ReferencedAssemblies = $refAssemblies
|
|
ErrorAction = 'Stop'
|
|
}
|
|
if($PSVersionTable.PSEdition -eq 'Core') {
|
|
$addTypeParams['CompilerOptions'] = '/nowarn:1701;1702'
|
|
}
|
|
|
|
Add-Type -TypeDefinition @"
|
|
using System;
|
|
using System.Diagnostics;
|
|
using System.Threading.Tasks;
|
|
|
|
public static class IMMsalBrowserLauncher {
|
|
// Default browser command when `$env:BROWSER` is unset. Set from PowerShell
|
|
// (which knows the platform) in Initialize-IMBrowserLauncher.
|
|
public static string DefaultCommand = "xdg-open";
|
|
public const string Marker = "IM_NO_BROWSER";
|
|
|
|
public static Task Open(Uri uri) {
|
|
string exe = ResolveBrowserCommand();
|
|
if (string.IsNullOrEmpty(exe)) {
|
|
throw new Exception(Marker + ": no web browser command could be resolved");
|
|
}
|
|
|
|
Process p = null;
|
|
try {
|
|
ProcessStartInfo psi = new ProcessStartInfo();
|
|
psi.FileName = exe;
|
|
// AbsoluteUri, NOT Uri.ToString().
|
|
//
|
|
// Uri.ToString() unescapes the %20 separators MSAL puts in "scope".
|
|
// ProcessStartInfo.Arguments then sees literal spaces and splits one URL
|
|
// into several argv entries. AbsoluteUri keeps all whitespace escaped,
|
|
// so the compatible Arguments property still passes one argument.
|
|
//
|
|
// Do not use ProcessStartInfo.ArgumentList here. It is preferable on
|
|
// modern .NET, but is absent from .NET Framework and makes this Add-Type
|
|
// fail during module import under Windows PowerShell 5.1.
|
|
psi.Arguments = uri.AbsoluteUri;
|
|
psi.UseShellExecute = false;
|
|
p = Process.Start(psi);
|
|
}
|
|
catch (Exception ex) {
|
|
throw new Exception(Marker + ": failed to launch '" + exe + "': " + ex.Message, ex);
|
|
}
|
|
|
|
if (p == null) {
|
|
throw new Exception(Marker + ": failed to launch '" + exe + "'");
|
|
}
|
|
|
|
// A real browser forks and keeps running, so WaitForExit(3000) returns
|
|
// false and we treat that as "launched OK". A quick non-zero exit means
|
|
// the launch failed (no handler / action failed).
|
|
if (p.WaitForExit(3000) && p.ExitCode != 0) {
|
|
throw new Exception(Marker + ": '" + exe + "' exited with code " + p.ExitCode);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static string ResolveBrowserCommand() {
|
|
string b = Environment.GetEnvironmentVariable("BROWSER");
|
|
if (!string.IsNullOrEmpty(b)) {
|
|
string first = b.Split(':')[0];
|
|
string[] parts = first.Split(new char[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length > 0) { return parts[0]; }
|
|
}
|
|
return DefaultCommand;
|
|
}
|
|
}
|
|
"@ @addTypeParams
|
|
|
|
if($IsMacOS) {
|
|
try { [IMMsalBrowserLauncher]::DefaultCommand = "open" } catch { }
|
|
}
|
|
}
|
|
|
|
# Surface the pending device code (stashed by IMMsalDeviceCodeHelper) into the
|
|
# GUI: copy the user code to the clipboard and show a status message telling the
|
|
# user where to enter it. Called from the -DeviceCode poll loop in
|
|
# Get-MsalAuthenticationToken. No-ops cleanly on a console/headless host (no
|
|
# UIProvider), where MSAL's callback already printed the code to the console.
|
|
function Show-DeviceCodeInstruction {
|
|
if(-not ("IMMsalDeviceCodeHelper" -as [type])) { return }
|
|
|
|
$code = $null
|
|
$url = $null
|
|
try { $code = [IMMsalDeviceCodeHelper]::UserCode } catch { }
|
|
try { $url = [IMMsalDeviceCodeHelper]::VerificationUrl } catch { }
|
|
if(-not $url) { $url = "https://microsoft.com/devicelogin" }
|
|
if(-not $code) { return }
|
|
|
|
$copied = $false
|
|
if($script:UIProvider) {
|
|
try {
|
|
$script:UIProvider.SetClipboardText($code)
|
|
$copied = $true
|
|
}
|
|
catch {
|
|
Write-LogDebug "Could not copy device code to clipboard: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
$prefix = if($copied) { "Sign-in code copied to clipboard. " } else { "" }
|
|
# -CancelText surfaces the overlay's Cancel button. The ACTION was armed by the
|
|
# wait loop that owns the cancellation token (Get-MsalAuthenticationToken), so
|
|
# this only has to ask for the button - device-code waits run up to 15 minutes
|
|
# and this is the only way out of an abandoned one.
|
|
Write-Status "Waiting for sign-in" "${prefix}Open $url and enter code $code" -CancelText "Cancel"
|
|
}
|
|
|
|
# Compile at load so the type is available to the login flow (and tests) without
|
|
# a lazy first-call cost. Guarded, so re-import (PS7 -Force) is a no-op.
|
|
Initialize-IMBrowserLauncher
|