diff --git a/README.md b/README.md
index 4b34bbff..01b5f793 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ The CLI is designed for authenticating and returning an access token for public
| Operating System | Integrated Windows Auth | Auth Broker Integration | Web Auth Flow | Device Code Flow | Token Caching | Multi-Account Support |
| ------------------------------------------ | ----------------------- | ----------------------- | ------------------------ | ---------------- | ------------- | ------------------------------- |
| Windows | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ `--domain` account filtering |
-| OSX (MacOS) | N/A | ⚠️ via Web Browser | ✅ | ✅ | ✅ | ⚠️ `--domain` account filtering |
+| OSX (macOS) | N/A | ✅ via Enterprise SSO | ✅ | ✅ | ✅ | ⚠️ `--domain` account filtering |
| Ubuntu (Linux) | N/A | ⚠️ via Edge | ⚠️ in GUI environments | ✅ | ✅ | ⚠️ `--domain` account filtering |
diff --git a/bin/mac/swap-cp.sh b/bin/mac/swap-cp.sh
new file mode 100755
index 00000000..4ce0595f
--- /dev/null
+++ b/bin/mac/swap-cp.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CP="/Applications/Company Portal.app"
+CP_OLD="/Applications/Company Portal (2602).app"
+CP_NEW="/Applications/Company Portal (new).app"
+
+get_version() {
+ defaults read "$1/Contents/Info" CFBundleShortVersionString 2>/dev/null || echo "not found"
+}
+
+current=$(get_version "$CP")
+echo "Current Company Portal: $current"
+echo ""
+
+if [ -d "$CP_OLD" ] && [ -d "$CP_NEW" ]; then
+ echo "⚠️ Both backups exist — something's off. Check /Applications manually."
+ ls -d /Applications/Company\ Portal*.app
+ exit 1
+fi
+
+if [ -d "$CP_OLD" ]; then
+ old_ver=$(get_version "$CP_OLD")
+ echo "Backup available: $old_ver (old/2602)"
+ echo ""
+ echo " [1] Switch to OLD ($old_ver)"
+ echo " [2] Do nothing"
+ read -p "Choice: " choice
+ if [ "$choice" = "1" ]; then
+ mv "$CP" "$CP_NEW"
+ mv "$CP_OLD" "$CP"
+ echo "✅ Swapped to OLD — now: $(get_version "$CP")"
+ fi
+elif [ -d "$CP_NEW" ]; then
+ new_ver=$(get_version "$CP_NEW")
+ echo "Backup available: $new_ver (new/updated)"
+ echo ""
+ echo " [1] Switch to NEW ($new_ver)"
+ echo " [2] Do nothing"
+ read -p "Choice: " choice
+ if [ "$choice" = "1" ]; then
+ mv "$CP" "$CP_OLD"
+ mv "$CP_NEW" "$CP"
+ echo "✅ Swapped to NEW — now: $(get_version "$CP")"
+ fi
+else
+ echo "No backup found. Nothing to swap."
+fi
diff --git a/bin/mac/test-macos-broker.sh b/bin/mac/test-macos-broker.sh
new file mode 100755
index 00000000..f7369b0a
--- /dev/null
+++ b/bin/mac/test-macos-broker.sh
@@ -0,0 +1,270 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Functional test script for macOS brokered auth changes
+# Tests AzureAuth CLI with Work IQ's 3P Graph app registration
+#
+# Usage:
+# ./bin/mac/test-macos-broker.sh # defaults (debug verbosity, 120s timeout)
+# AZUREAUTH_TEST_VERBOSITY=info ./bin/mac/test-macos-broker.sh # less noise
+# AZUREAUTH_TEST_VERBOSITY=trace ./bin/mac/test-macos-broker.sh # max detail
+# AZUREAUTH_TEST_TIMEOUT=60 ./bin/mac/test-macos-broker.sh # shorter timeout
+#
+# Each interactive test has a timeout (default 120s). If azureauth hangs
+# waiting for browser/broker, it will be killed and you can choose to
+# mark it as SKIP or FAIL, then the script continues to the next test.
+#
+# You can also Ctrl+C during any individual test — the script traps it
+# and moves on.
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+AZUREAUTH="$REPO_ROOT/src/AzureAuth/bin/Debug/net8.0/azureauth"
+CLIENT="ba081686-5d24-4bc6-a0d6-d034ecffed87"
+TENANT="common"
+RESOURCE="https://graph.microsoft.com"
+TIMEOUT="${AZUREAUTH_TEST_TIMEOUT:-120}"
+VERBOSITY="${AZUREAUTH_TEST_VERBOSITY:-debug}" # debug, trace, info, warn
+
+PASS=0
+FAIL=0
+SKIP=0
+
+header() {
+ echo ""
+ echo "========================================"
+ echo " $1"
+ echo "========================================"
+}
+
+result() {
+ local name="$1" exit_code="$2" expected="$3"
+ if [ "$exit_code" -eq "$expected" ]; then
+ echo "✅ PASS: $name (exit=$exit_code, expected=$expected)"
+ PASS=$((PASS + 1))
+ else
+ echo "❌ FAIL: $name (exit=$exit_code, expected=$expected)"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+# Run azureauth with a timeout. On Ctrl+C or timeout, offer skip/fail.
+# Usage: run_test "Test Name" expected_exit args...
+run_test() {
+ local test_name="$1" expected_exit="$2"
+ shift 2
+
+ echo ""
+ echo "→ Running: azureauth $*"
+ echo " (timeout: ${TIMEOUT}s — Ctrl+C to abort this test)"
+ echo ""
+
+ local interrupted=false
+ local pid=""
+
+ # Trap SIGINT (Ctrl+C) for this test only
+ trap 'interrupted=true; [ -n "$pid" ] && kill "$pid" 2>/dev/null' INT
+
+ set +e
+ # Run azureauth in background, then wait with timeout
+ "$AZUREAUTH" "$@" 2>&1 &
+ pid=$!
+
+ # Wait up to TIMEOUT seconds for the process to finish
+ local elapsed=0
+ while [ "$elapsed" -lt "$TIMEOUT" ] && kill -0 "$pid" 2>/dev/null; do
+ sleep 1
+ ((elapsed++))
+ if [ "$interrupted" = true ]; then
+ break
+ fi
+ done
+
+ # If still running after timeout, kill it
+ if kill -0 "$pid" 2>/dev/null; then
+ kill "$pid" 2>/dev/null
+ wait "$pid" 2>/dev/null
+ if [ "$interrupted" = false ]; then
+ echo ""
+ echo "⏱️ Test timed out after ${TIMEOUT}s"
+ fi
+ interrupted=true
+ EXIT_CODE=124
+ else
+ wait "$pid"
+ EXIT_CODE=$?
+ fi
+ pid=""
+ set -e
+
+ # Restore default SIGINT behavior
+ trap - INT
+
+ if [ "$interrupted" = true ]; then
+ echo ""
+ echo "Test was interrupted/timed out."
+ read -p "Mark as [s]kip or [f]ail? (s/f, default=s): " choice &1 | tail -3
+
+if [ ! -x "$AZUREAUTH" ]; then
+ echo "❌ Build failed — binary not found at $AZUREAUTH"
+ exit 1
+fi
+echo "✅ Binary ready: $AZUREAUTH"
+echo " Version: $("$AZUREAUTH" --version)"
+
+# ── Step 0.5: CP version info ─────────────────────────────────
+header "Step 0.5: Company Portal status"
+CP_PLIST="/Applications/Company Portal.app/Contents/Info.plist"
+if [ -f "$CP_PLIST" ]; then
+ CP_VERSION=$(defaults read "/Applications/Company Portal.app/Contents/Info" CFBundleShortVersionString 2>/dev/null || echo "unknown")
+ echo "Company Portal version: $CP_VERSION"
+ # Extract release number (middle segment of 5.RRRR.B)
+ RELEASE=$(echo "$CP_VERSION" | awk -F. '{print $2}')
+ if [ "$RELEASE" -ge 2603 ] 2>/dev/null; then
+ echo "⚡ CP >= 2603 — broker tests WILL attempt real broker auth"
+ BROKER_AVAILABLE=true
+ else
+ echo "⚠️ CP $CP_VERSION (release $RELEASE) < 2603 — broker will be gated off"
+ BROKER_AVAILABLE=false
+ fi
+else
+ echo "⚠️ Company Portal not installed — broker will be gated off"
+ BROKER_AVAILABLE=false
+fi
+
+# ── Test 1: Broker-only mode (opt-in) ────────────────────────
+header "Test 1: Broker-only mode (--mode broker)"
+if [ "$BROKER_AVAILABLE" = true ]; then
+ echo "CP >= 2603 detected — this will attempt real broker auth"
+ echo "Expect: broker interactive prompt via Enterprise SSO Extension"
+ EXPECTED_EXIT=0
+else
+ echo "CP < 2603 or not installed — expecting clear error about Company Portal"
+ echo "Expect: InvalidOperationException with CP version/path info"
+ EXPECTED_EXIT=1
+fi
+run_test "Broker-only (opt-in)" "$EXPECTED_EXIT" \
+ aad --client "$CLIENT" --tenant "$TENANT" \
+ --resource "$RESOURCE" \
+ --mode broker --output json --verbosity "$VERBOSITY"
+
+# ── Test 2: Broker + web combined — COMMENTED OUT ─────────────
+# This app requires broker (token protection CA policy), so web auth
+# will hang indefinitely waiting for a redirect that never comes.
+# Uncomment for apps that support both broker and web auth.
+#
+# header "Test 2: Broker + web combined (--mode broker --mode web)"
+# if [ "$BROKER_AVAILABLE" = true ]; then
+# echo "CP >= 2603 — broker will be tried first, web as fallback"
+# EXPECTED_EXIT=0
+# else
+# echo "CP unavailable — broker skipped, falls through to web"
+# EXPECTED_EXIT=0
+# fi
+# run_test "Broker + web combined" "$EXPECTED_EXIT" \
+# aad --client "$CLIENT" --tenant "$TENANT" \
+# --resource "$RESOURCE" \
+# --mode broker --mode web --output json --verbosity "$VERBOSITY"
+
+# ── Test 3: Trace verbosity — verify CP diagnostics in logs ───
+header "Test 3: Trace verbosity — CP diagnostic logging"
+echo "Running with --verbosity trace to verify Company Portal metadata is logged."
+echo "🔍 Watch for: CP path, raw version output, release parsing"
+if [ "$BROKER_AVAILABLE" = true ]; then
+ EXPECTED_EXIT=0
+else
+ EXPECTED_EXIT=1
+fi
+run_test "Trace CP diagnostics" "$EXPECTED_EXIT" \
+ aad --client "$CLIENT" --tenant "$TENANT" \
+ --resource "$RESOURCE" \
+ --mode broker --output json --verbosity trace
+
+# ── Test 4: Clear cache ───────────────────────────────────────
+header "Test 4: Clear token cache"
+run_test "Cache clear" 0 \
+ aad --client "$CLIENT" --tenant "$TENANT" \
+ --resource "$RESOURCE" \
+ --clear --verbosity "$VERBOSITY"
+
+# ── Test 5: Broker + web fallthrough — COMMENTED OUT ──────────
+# Same issue as Test 2: this app requires broker, so web will hang.
+# Uncomment for apps that support both broker and web auth.
+#
+# header "Test 5: Broker + web fallthrough (--mode broker --mode web)"
+# echo "Tests the fallthrough pattern: broker tried first, web as fallback."
+# if [ "$BROKER_AVAILABLE" = true ]; then
+# echo "CP available — broker should succeed silently from Test 1 cache"
+# EXPECTED_EXIT=0
+# else
+# echo "CP unavailable — broker skipped, falls through to web"
+# EXPECTED_EXIT=0
+# fi
+# run_test "Broker + web fallthrough" "$EXPECTED_EXIT" \
+# aad --client "$CLIENT" --tenant "$TENANT" \
+# --resource "$RESOURCE" \
+# --mode broker --mode web --output json --verbosity "$VERBOSITY"
+
+# ── Test 6: Clear cache (before re-testing broker interactive) ─
+header "Test 6: Clear token cache"
+run_test "Cache clear (pre-broker retest)" 0 \
+ aad --client "$CLIENT" --tenant "$TENANT" \
+ --resource "$RESOURCE" \
+ --clear --verbosity "$VERBOSITY"
+
+# ── Test 7: Broker interactive again (after cache clear) ──────
+header "Test 7: Broker interactive (after cache clear)"
+if [ "$BROKER_AVAILABLE" = true ]; then
+ echo "Cache was just cleared — broker must prompt interactively again"
+ echo "Expect: broker account picker / SSO Extension prompt"
+ EXPECTED_EXIT=0
+else
+ echo "CP unavailable — broker skipped, CachedAuth only (will fail)"
+ EXPECTED_EXIT=1
+fi
+run_test "Broker interactive (re-prompt)" "$EXPECTED_EXIT" \
+ aad --client "$CLIENT" --tenant "$TENANT" \
+ --resource "$RESOURCE" \
+ --mode broker --output json --verbosity "$VERBOSITY"
+
+# ── Test 8: Final cache clear ─────────────────────────────────
+header "Test 8: Final cache clear"
+run_test "Cache clear (final)" 0 \
+ aad --client "$CLIENT" --tenant "$TENANT" \
+ --resource "$RESOURCE" \
+ --clear --verbosity "$VERBOSITY"
+
+# ── Summary ────────────────────────────────────────────────────
+header "Results"
+echo "✅ Passed: $PASS"
+echo "⏭️ Skipped: $SKIP"
+echo "❌ Failed: $FAIL"
+echo ""
+echo "Broker available: $BROKER_AVAILABLE"
+if [ "$BROKER_AVAILABLE" = false ]; then
+ echo "ℹ️ To test actual broker auth, upgrade Company Portal to >= 5.2603.x"
+fi
+echo ""
+echo "Tip: Set AZUREAUTH_TEST_TIMEOUT=60 to change the per-test timeout (default: 30s)"
+echo ""
+
+if [ "$FAIL" -gt 0 ]; then
+ exit 1
+fi
diff --git a/docs/usage.md b/docs/usage.md
index 28587897..2790ffbc 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -1,9 +1,9 @@
# Usage
AzureAuth is a generic Azure credential provider. It currently supports the following modes of [public client authentication](https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-client-applications) (i.e., authenticating a human user.)
-* [IWA (Integrated Windows Authentication)](https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-integrated-windows-authentication)
-* [WAM (Web Account Manager)](https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-wam) (Windows only brokered authentication)
+* [IWA (Integrated Windows Authentication)](https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-integrated-windows-authentication) (Windows only)
+* [Brokered Authentication](https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-wam) (Windows via WAM, macOS via Enterprise SSO Extension)
* [Embedded Web View](https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-web-browsers) (Windows Only)
-* [System Web Browser](https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-web-browsers) (Used on OSX in-place of Embedded Web View)
+* [System Web Browser](https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-web-browsers) (Used on macOS in-place of Embedded Web View)
* [Device Code Flow](https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Device-Code-Flow) (All platforms, terminal interface only).
## `aad` subcommand
@@ -23,6 +23,22 @@ The `azureauth aad` subcommand is a "pass-through" for using [MSAL.NET](https://

+2b. Configure redirect URIs for the **macOS Enterprise SSO Extension** (the macOS broker)
+ 1. Select the **Authentication** blade.
+ 2. Under Platform configurations, find **Mobile and desktop applications**.
+ 3. Select **Add URI** and enter
+ ```
+ msauth.com.msauth.unsignedapp://auth
+ ```
+ 4. Select **Save**.
+
+ > **Note:** macOS brokered authentication requires:
+ > - **Company Portal** installed on the device
+ > - Device is **MDM-compliant**
+ > - **Enterprise SSO Extension** is running
+ >
+ > If these prerequisites are not met, AzureAuth will automatically fall back to system web browser authentication.
+
3. Configure redirect URIs for the **system web browser**
1. Select the **Authentication** blade.
2. Under Platform configurations, find **Mobile and desktop applications**
diff --git a/src/AdoPat/AdoPat.csproj b/src/AdoPat/AdoPat.csproj
index a84af207..51ce705e 100644
--- a/src/AdoPat/AdoPat.csproj
+++ b/src/AdoPat/AdoPat.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/src/AzureAuth.Test/AuthModeExtensionsTest.cs b/src/AzureAuth.Test/AuthModeExtensionsTest.cs
index 0d77d915..6707191b 100644
--- a/src/AzureAuth.Test/AuthModeExtensionsTest.cs
+++ b/src/AzureAuth.Test/AuthModeExtensionsTest.cs
@@ -58,13 +58,15 @@ public void Filterinteraction_Interactive_Auth_Disabled(string envVar)
this.logTarget.Logs.Should().ContainInOrder("Interactive authentication is disabled.", "Only Integrated Windows Authentication will be attempted.");
}
#else
+ [Test]
public void CombinedAuthMode_Allowed()
{
// Arrange
this.envMock.Setup(e => e.Get(EnvVars.NoUser)).Returns(string.Empty);
this.envMock.Setup(e => e.Get("Corext_NonInteractive")).Returns(string.Empty);
- var subject = new[] { AuthMode.Web, AuthMode.DeviceCode };
+ // Default on macOS is Web only (broker is opt-in).
+ var subject = new[] { AuthMode.Web };
// Act + Assert
subject.Combine().PreventInteractionIfNeeded(this.envMock.Object, this.logger).Should().Be(AuthMode.Default);
@@ -73,7 +75,7 @@ public void CombinedAuthMode_Allowed()
[TestCase("AZUREAUTH_NO_USER")]
[TestCase("Corext_NonInteractive")]
- public void Filterinteraction_Interactive_Auth_Disabled(string envVar)
+ public void Filterinteraction_Interactive_Auth_Disabled_NoBroker(string envVar)
{
// Arrange
this.envMock.Setup(e => e.Get(envVar)).Returns("1");
@@ -83,6 +85,19 @@ public void Filterinteraction_Interactive_Auth_Disabled(string envVar)
subject.Combine().PreventInteractionIfNeeded(this.envMock.Object, this.logger).Should().Be(AuthMode.None);
this.logTarget.Logs.Should().ContainInOrder("Interactive authentication is disabled.");
}
+
+ [TestCase("AZUREAUTH_NO_USER")]
+ [TestCase("Corext_NonInteractive")]
+ public void Filterinteraction_Interactive_Auth_Disabled_WithBroker(string envVar)
+ {
+ // Arrange
+ this.envMock.Setup(e => e.Get(envVar)).Returns("1");
+ var subject = new[] { AuthMode.Broker, AuthMode.Web, AuthMode.DeviceCode };
+
+ // Act + Assert
+ subject.Combine().PreventInteractionIfNeeded(this.envMock.Object, this.logger).Should().Be(AuthMode.Broker);
+ this.logTarget.Logs.Should().ContainInOrder("Interactive authentication is disabled.", "Only Broker silent authentication will be attempted.");
+ }
#endif
}
}
diff --git a/src/AzureAuth/AuthModeExtensions.cs b/src/AzureAuth/AuthModeExtensions.cs
index e11add48..e3bb3751 100644
--- a/src/AzureAuth/AuthModeExtensions.cs
+++ b/src/AzureAuth/AuthModeExtensions.cs
@@ -28,7 +28,14 @@ public static AuthMode PreventInteractionIfNeeded(this AuthMode authMode, IEnv e
logger.LogWarning($"Only Integrated Windows Authentication will be attempted.");
return AuthMode.IWA;
#else
- return 0;
+ // Keep broker for silent auth on macOS, where the broker can resolve tokens silently.
+ var silentMode = authMode & AuthMode.Broker;
+ if (silentMode != 0)
+ {
+ logger.LogWarning($"Only Broker silent authentication will be attempted.");
+ }
+
+ return silentMode;
#endif
}
diff --git a/src/AzureAuth/Commands/CommandAad.cs b/src/AzureAuth/Commands/CommandAad.cs
index fd597cdf..04a68b83 100644
--- a/src/AzureAuth/Commands/CommandAad.cs
+++ b/src/AzureAuth/Commands/CommandAad.cs
@@ -65,7 +65,7 @@ public class CommandAad
[possible values: {AuthModeAllowedValues}]";
#else
public const string AuthModeHelperText = $@"Authentication mode. Repeated invocations allowed
-[default: web]
+[default: broker, then web]
[possible values: {AuthModeAllowedValues}]";
#endif
@@ -90,7 +90,7 @@ public class CommandAad
#if PlatformWindows
public const string AuthModeAllowedValues = "all, iwa, broker, web, devicecode";
#else
- public const string AuthModeAllowedValues = "all, web, devicecode";
+ public const string AuthModeAllowedValues = "all, broker, web, devicecode";
#endif
private const string ResourceOption = "--resource";
diff --git a/src/AzureAuth/Program.cs b/src/AzureAuth/Program.cs
index 7a94c4c7..f26910e6 100644
--- a/src/AzureAuth/Program.cs
+++ b/src/AzureAuth/Program.cs
@@ -6,6 +6,7 @@ namespace Microsoft.Authentication.AzureAuth
using System;
using System.Runtime.InteropServices;
using System.Text;
+ using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils;
@@ -13,6 +14,7 @@ namespace Microsoft.Authentication.AzureAuth
using Microsoft.Authentication.MSALWrapper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+ using Microsoft.Identity.Client.Utils;
using Microsoft.Office.Lasso;
using Microsoft.Office.Lasso.Telemetry;
@@ -22,6 +24,34 @@ namespace Microsoft.Authentication.AzureAuth
public class Program
{
private static void Main(string[] args)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ // macOS brokered auth requires interactive calls on the main thread.
+ // Start the MSAL message loop on main, dispatch CLI work to a background thread.
+ var scheduler = MacMainThreadScheduler.Instance();
+ var cliTask = Task.Run(() =>
+ {
+ try
+ {
+ MainInner(args);
+ }
+ finally
+ {
+ scheduler.Stop();
+ }
+ });
+
+ scheduler.StartMessageLoop();
+ cliTask.GetAwaiter().GetResult();
+ }
+ else
+ {
+ MainInner(args);
+ }
+ }
+
+ private static void MainInner(string[] args)
{
// Use UTF-8 output encoding.
// This will impact the NLog Console Target as well as any other Console usage.
diff --git a/src/MSALWrapper.Benchmark/MSALWrapper.Benchmark.csproj b/src/MSALWrapper.Benchmark/MSALWrapper.Benchmark.csproj
index 19e498dd..69f7ba87 100644
--- a/src/MSALWrapper.Benchmark/MSALWrapper.Benchmark.csproj
+++ b/src/MSALWrapper.Benchmark/MSALWrapper.Benchmark.csproj
@@ -18,7 +18,7 @@
-
+
@@ -32,6 +32,6 @@
-
+
\ No newline at end of file
diff --git a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs
index 2c7336ac..21d4ff43 100644
--- a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs
+++ b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs
@@ -3,6 +3,7 @@
namespace Microsoft.Authentication.MSALWrapper.Test
{
+ using System;
using System.Collections.Generic;
using System.Linq;
@@ -243,6 +244,10 @@ public void AllModes_Windows10Or11()
[Platform("MacOsX")]
public void AllModes_Mac()
{
+ this.MockIsMacOS(true);
+ this.MockIsMacOSBrokerAvailable(true);
+ this.MockIsWindows10Or11(false);
+
IEnumerable subject = this.Subject(AuthMode.All);
this.pcaWrapperMock.VerifyAll();
@@ -252,7 +257,7 @@ public void AllModes_Mac()
.Should()
.BeEquivalentTo(new[]
{
- typeof(CachedAuth),
+ typeof(Broker),
typeof(Web),
typeof(DeviceCode),
});
@@ -262,11 +267,9 @@ public void AllModes_Mac()
[Platform("MacOsx")]
public void DefaultModes_Not_Windows()
{
- // On non-windows platforms the Default Authmode doesn't contain "Broker" as an option to start with.
- // so we short circuit checking the platform and expect it to not be called.
+ // On macOS, default mode is Web only (broker is opt-in via --mode broker).
var subject = this.Subject(AuthMode.Default);
- this.platformUtilsMock.VerifyAll();
subject.Should().HaveCount(2);
subject
.Select(a => a.GetType())
@@ -276,6 +279,41 @@ public void DefaultModes_Not_Windows()
typeof(Web));
}
+ [Test]
+ [Platform("MacOsX")]
+ public void BrokerRequested_Mac_CP_Unavailable_SkipsBroker()
+ {
+ this.MockIsWindows10Or11(false);
+ this.MockIsMacOS(true);
+ this.MockIsMacOSBrokerAvailable(false);
+
+ // Broker is silently skipped; only CachedAuth remains when no other modes are requested.
+ IEnumerable subject = this.Subject(AuthMode.Broker);
+
+ subject.Should().HaveCount(1);
+ subject.First().Should().BeOfType();
+ }
+
+ [Test]
+ [Platform("MacOsX")]
+ public void BrokerAndWeb_Mac_CP_Unavailable_FallsThrough()
+ {
+ this.MockIsWindows10Or11(false);
+ this.MockIsMacOS(true);
+ this.MockIsMacOSBrokerAvailable(false);
+
+ // Broker is skipped but web is still added — fall-through pattern.
+ IEnumerable subject = this.Subject(AuthMode.Broker | AuthMode.Web);
+
+ subject.Should().HaveCount(2);
+ subject
+ .Select(flow => flow.GetType())
+ .Should()
+ .ContainInOrder(
+ typeof(CachedAuth),
+ typeof(Web));
+ }
+
private void MockIsWindows10Or11(bool value)
{
this.platformUtilsMock.Setup(p => p.IsWindows10Or11()).Returns(value);
@@ -285,5 +323,15 @@ private void MockIsWindows(bool value)
{
this.platformUtilsMock.Setup(p => p.IsWindows()).Returns(value);
}
+
+ private void MockIsMacOS(bool value)
+ {
+ this.platformUtilsMock.Setup(p => p.IsMacOS()).Returns(value);
+ }
+
+ private void MockIsMacOSBrokerAvailable(bool value)
+ {
+ this.platformUtilsMock.Setup(p => p.IsMacOSBrokerAvailable()).Returns(value);
+ }
}
}
diff --git a/src/MSALWrapper.Test/AuthFlow/BrokerMacOSTest.cs b/src/MSALWrapper.Test/AuthFlow/BrokerMacOSTest.cs
new file mode 100644
index 00000000..d6886f19
--- /dev/null
+++ b/src/MSALWrapper.Test/AuthFlow/BrokerMacOSTest.cs
@@ -0,0 +1,215 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Authentication.MSALWrapper.Test
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ using FluentAssertions;
+
+ using Microsoft.Authentication.MSALWrapper;
+ using Microsoft.Authentication.TestHelper;
+ using Microsoft.Extensions.Logging;
+ using Microsoft.Identity.Client;
+ using Microsoft.IdentityModel.JsonWebTokens;
+
+ using Moq;
+
+ using NLog.Targets;
+
+ using NUnit.Framework;
+
+ ///
+ /// Tests for macOS-specific broker authentication behavior.
+ /// Uses a mock IPlatformUtils that reports IsMacOS()=true to simulate macOS.
+ ///
+ internal class BrokerMacOSTest : AuthFlowTestBase
+ {
+ private Mock mockPlatformUtils;
+
+ [SetUp]
+ public new void Setup()
+ {
+ this.mockPlatformUtils = new Mock(MockBehavior.Strict);
+ this.mockPlatformUtils.Setup(p => p.IsMacOS()).Returns(true);
+ }
+
+ public AuthFlow.Broker Subject() => new AuthFlow.Broker(
+ this.logger,
+ this.authParameters,
+ pcaWrapper: this.mockPca.Object,
+ promptHint: PromptHint,
+ platformUtils: this.mockPlatformUtils.Object);
+
+ [Test]
+ public async Task MacOS_GetTokenSilent_WithCachedAccount_Success()
+ {
+ // Cached account exists in the MSAL cache — silent auth succeeds.
+ this.SetupCachedAccount();
+ this.SetupAccountUsername();
+ this.SetupGetTokenSilentSuccess();
+
+ AuthFlowResult result = await this.Subject().GetTokenAsync();
+
+ result.TokenResult.Should().Be(this.testToken);
+ result.TokenResult.IsSilent.Should().BeTrue();
+ result.Errors.Should().BeEmpty();
+ result.AuthFlowName.Should().Be("broker");
+ }
+
+ [Test]
+ public async Task MacOS_NoCachedAccount_PersistedAccountFound_SilentSuccess()
+ {
+ // No cached account via preferred domain lookup, but a persisted username
+ // matches an account in the MSAL cache — silent auth succeeds.
+ this.SetupCachedAccount(false);
+
+ // Save a persisted account so ResolveAccountAsync can find it.
+ var store = new DefaultAccountStore(this.logger);
+ store.SaveDefaultAccount(TestUsername, ClientId, TenantId.ToString());
+
+ try
+ {
+ // Set up accounts returned by TryToGetCachedAccountsAsync
+ this.mockAccount.Setup(a => a.Username).Returns(TestUsername);
+ this.mockPca
+ .Setup(pca => pca.TryToGetCachedAccountsAsync(null))
+ .ReturnsAsync(new List { this.mockAccount.Object });
+
+ this.mockPca
+ .Setup(pca => pca.GetTokenSilentAsync(Scopes, this.mockAccount.Object, It.IsAny()))
+ .ReturnsAsync(this.testToken);
+
+ AuthFlowResult result = await this.Subject().GetTokenAsync();
+
+ result.TokenResult.Should().Be(this.testToken);
+ result.TokenResult.IsSilent.Should().BeTrue();
+ result.Errors.Should().BeEmpty();
+ }
+ finally
+ {
+ store.ClearDefaultAccount(ClientId, TenantId.ToString());
+ }
+ }
+
+ [Test]
+ public async Task MacOS_NoCachedAccount_NoPersistedAccount_Interactive_Success()
+ {
+ // Ensure no stale persisted account from other tests.
+ var store = new DefaultAccountStore(this.logger);
+ store.ClearDefaultAccount(ClientId, TenantId.ToString());
+
+ // No cached or persisted account — falls through to interactive auth.
+ // Use SetupSequence so the first call returns null (ResolveAccountAsync)
+ // and the second call returns the account (PersistDefaultAccount).
+ this.mockPca
+ .SetupSequence(pca => pca.TryToGetCachedAccountAsync(It.IsAny()))
+ .ReturnsAsync((IAccount)null)
+ .ReturnsAsync(this.mockAccount.Object);
+
+ this.mockAccount.Setup(a => a.Username).Returns(TestUsername);
+
+ this.SetupWithPromptHint();
+ this.SetupGetTokenInteractiveSuccess(withAccount: false);
+
+ try
+ {
+ AuthFlowResult result = await this.Subject().GetTokenAsync();
+
+ result.TokenResult.Should().Be(this.testToken);
+ result.TokenResult.IsSilent.Should().BeFalse();
+ result.Errors.Should().BeEmpty();
+
+ // Verify the account was persisted for future runs
+ var persisted = store.GetDefaultAccount(ClientId, TenantId.ToString());
+ persisted.Should().Be(TestUsername);
+ }
+ finally
+ {
+ store.ClearDefaultAccount(ClientId, TenantId.ToString());
+ }
+ }
+
+ [Test]
+ public async Task MacOS_GeneralException_Windows_Would_Rethrow()
+ {
+ // Verify that on a "Windows" platform mock, general exceptions ARE rethrown.
+ // This is the control test proving the macOS exception filter is meaningful.
+ var windowsPlatform = new Mock(MockBehavior.Strict);
+ windowsPlatform.Setup(p => p.IsMacOS()).Returns(false);
+
+ var broker = new AuthFlow.Broker(
+ this.logger,
+ this.authParameters,
+ pcaWrapper: this.mockPca.Object,
+ promptHint: PromptHint,
+ platformUtils: windowsPlatform.Object);
+
+ this.SetupCachedAccount();
+ this.SetupAccountUsername();
+ this.SetupGetTokenSilentReturnsNull();
+ this.SetupWithPromptHint();
+
+ this.mockPca
+ .Setup(pca => pca.GetTokenInteractiveAsync(Scopes, this.mockAccount.Object, It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Broker crash"));
+
+ Func act = async () => await broker.GetTokenAsync();
+
+ await act.Should().ThrowExactlyAsync().WithMessage("Broker crash");
+ }
+
+ [Test]
+ public async Task MacOS_SilentFails_Interactive_RetriesWithClaims_Success()
+ {
+ // No cached account, interactive gets MsalUiRequiredException, retries with claims.
+ // Use SetupSequence: first returns null (ResolveAccountAsync),
+ // second returns account (PersistDefaultAccount).
+ this.mockPca
+ .SetupSequence(pca => pca.TryToGetCachedAccountAsync(It.IsAny()))
+ .ReturnsAsync((IAccount)null)
+ .ReturnsAsync(this.mockAccount.Object);
+
+ this.mockAccount.Setup(a => a.Username).Returns(TestUsername);
+
+ this.SetupWithPromptHint();
+ this.SetupGetTokenInteractiveMsalUiRequiredException(null);
+ this.SetupGetTokenInteractiveWithClaimsSuccess();
+
+ var store = new DefaultAccountStore(this.logger);
+
+ try
+ {
+ AuthFlowResult result = await this.Subject().GetTokenAsync();
+
+ result.TokenResult.Should().Be(this.testToken);
+ result.Errors.Should().HaveCount(1);
+ result.Errors[0].Should().BeOfType(typeof(MsalUiRequiredException));
+ }
+ finally
+ {
+ store.ClearDefaultAccount(ClientId, TenantId.ToString());
+ }
+ }
+
+ [Test]
+ public async Task MacOS_GetTokenSilent_MsalServiceException_ReturnsError()
+ {
+ this.SetupCachedAccount();
+ this.SetupAccountUsername();
+
+ this.mockPca
+ .Setup(pca => pca.GetTokenSilentAsync(Scopes, this.mockAccount.Object, It.IsAny()))
+ .ThrowsAsync(new MsalServiceException("1", "Service error"));
+
+ AuthFlowResult result = await this.Subject().GetTokenAsync();
+
+ result.TokenResult.Should().BeNull();
+ result.Errors.Should().HaveCount(1);
+ result.Errors[0].Should().BeOfType(typeof(MsalServiceException));
+ }
+ }
+}
diff --git a/src/MSALWrapper.Test/AuthFlow/BrokerTest.cs b/src/MSALWrapper.Test/AuthFlow/BrokerTest.cs
index 0b91d736..11a7b2c6 100644
--- a/src/MSALWrapper.Test/AuthFlow/BrokerTest.cs
+++ b/src/MSALWrapper.Test/AuthFlow/BrokerTest.cs
@@ -24,7 +24,17 @@ namespace Microsoft.Authentication.MSALWrapper.Test
internal class BrokerTest : AuthFlowTestBase
{
- public AuthFlow.Broker Subject() => new AuthFlow.Broker(this.logger, this.authParameters, pcaWrapper: this.mockPca.Object, promptHint: PromptHint);
+ private Mock mockPlatformUtils;
+
+ [SetUp]
+ public new void Setup()
+ {
+ this.mockPlatformUtils = new Mock(MockBehavior.Strict);
+ // Default to Windows behavior so existing tests keep working.
+ this.mockPlatformUtils.Setup(p => p.IsMacOS()).Returns(false);
+ }
+
+ public AuthFlow.Broker Subject() => new AuthFlow.Broker(this.logger, this.authParameters, pcaWrapper: this.mockPca.Object, promptHint: PromptHint, platformUtils: this.mockPlatformUtils.Object);
[Test]
public async Task GetTokenSilent_Success()
diff --git a/src/MSALWrapper.Test/AuthModeTest.cs b/src/MSALWrapper.Test/AuthModeTest.cs
index 992bdde1..073530e9 100644
--- a/src/MSALWrapper.Test/AuthModeTest.cs
+++ b/src/MSALWrapper.Test/AuthModeTest.cs
@@ -96,10 +96,11 @@ public void WebOrDeviceCodeIsNotBrokerOrIWA()
[Test]
public void AllIsAll()
{
- (AuthMode.Web | AuthMode.DeviceCode).Should().Be(AuthMode.All);
+ (AuthMode.Broker | AuthMode.Web | AuthMode.DeviceCode).Should().Be(AuthMode.All);
}
- [TestCase(AuthMode.All, false)]
+ [TestCase(AuthMode.All, true)]
+ [TestCase(AuthMode.Broker, true)]
[TestCase(AuthMode.Web, false)]
[TestCase(AuthMode.DeviceCode, false)]
public void BrokerIsExpected(AuthMode subject, bool expected)
diff --git a/src/MSALWrapper.Test/DefaultAccountStoreTest.cs b/src/MSALWrapper.Test/DefaultAccountStoreTest.cs
new file mode 100644
index 00000000..77fd205e
--- /dev/null
+++ b/src/MSALWrapper.Test/DefaultAccountStoreTest.cs
@@ -0,0 +1,167 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Authentication.MSALWrapper.Test
+{
+ using System;
+ using System.IO;
+
+ using FluentAssertions;
+
+ using Microsoft.Authentication.TestHelper;
+ using Microsoft.Extensions.Logging;
+
+ using NLog.Targets;
+
+ using NUnit.Framework;
+
+ internal class DefaultAccountStoreTest
+ {
+ private static readonly Guid TestClientId = new Guid("5af6def2-05ec-4cab-b9aa-323d75b5df40");
+ private const string TestTenantId = "8254f6f7-a09f-4752-8bd6-391adc3b912e";
+ private const string TestUsername = "testuser@contoso.com";
+
+ private ILogger logger;
+ private MemoryTarget logTarget;
+ private string tempDir;
+
+ [SetUp]
+ public void SetUp()
+ {
+ (this.logger, this.logTarget) = MemoryLogger.Create();
+
+ // Use a temp directory to avoid polluting the real ~/.azureauth
+ this.tempDir = Path.Combine(Path.GetTempPath(), "azureauth_test_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this.tempDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(this.tempDir))
+ {
+ Directory.Delete(this.tempDir, recursive: true);
+ }
+ }
+
+ [Test]
+ public void SaveAndGet_RoundTrip()
+ {
+ var store = new DefaultAccountStore(this.logger);
+ store.SaveDefaultAccount(TestUsername, TestClientId, TestTenantId);
+
+ var result = store.GetDefaultAccount(TestClientId, TestTenantId);
+
+ result.Should().Be(TestUsername);
+ }
+
+ [Test]
+ public void GetDefaultAccount_NoFile_ReturnsNull()
+ {
+ var store = new DefaultAccountStore(this.logger);
+
+ // Use a unique client/tenant that will never have a file
+ var result = store.GetDefaultAccount(Guid.NewGuid(), "nonexistent-tenant");
+
+ result.Should().BeNull();
+ }
+
+ [Test]
+ public void SaveDefaultAccount_NullUsername_DoesNothing()
+ {
+ var store = new DefaultAccountStore(this.logger);
+ var uniqueClientId = Guid.NewGuid();
+ var uniqueTenantId = Guid.NewGuid().ToString();
+
+ store.SaveDefaultAccount(null, uniqueClientId, uniqueTenantId);
+
+ var result = store.GetDefaultAccount(uniqueClientId, uniqueTenantId);
+ result.Should().BeNull();
+ }
+
+ [Test]
+ public void SaveDefaultAccount_EmptyUsername_DoesNothing()
+ {
+ var store = new DefaultAccountStore(this.logger);
+ var uniqueClientId = Guid.NewGuid();
+ var uniqueTenantId = Guid.NewGuid().ToString();
+
+ store.SaveDefaultAccount(string.Empty, uniqueClientId, uniqueTenantId);
+
+ var result = store.GetDefaultAccount(uniqueClientId, uniqueTenantId);
+ result.Should().BeNull();
+ }
+
+ [Test]
+ public void ClearDefaultAccount_RemovesFile()
+ {
+ var store = new DefaultAccountStore(this.logger);
+ store.SaveDefaultAccount(TestUsername, TestClientId, TestTenantId);
+
+ store.ClearDefaultAccount(TestClientId, TestTenantId);
+
+ var result = store.GetDefaultAccount(TestClientId, TestTenantId);
+ result.Should().BeNull();
+ }
+
+ [Test]
+ public void ClearDefaultAccount_NoFile_DoesNotThrow()
+ {
+ var store = new DefaultAccountStore(this.logger);
+
+ // Should not throw when clearing a non-existent account
+ Action act = () => store.ClearDefaultAccount(Guid.NewGuid(), "nonexistent-tenant");
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ public void SaveDefaultAccount_OverwritesExisting()
+ {
+ var store = new DefaultAccountStore(this.logger);
+ store.SaveDefaultAccount("old@contoso.com", TestClientId, TestTenantId);
+ store.SaveDefaultAccount("new@contoso.com", TestClientId, TestTenantId);
+
+ var result = store.GetDefaultAccount(TestClientId, TestTenantId);
+ result.Should().Be("new@contoso.com");
+ }
+
+ [Test]
+ public void GetDefaultAccount_CorruptFile_ReturnsNull()
+ {
+ // Write a corrupt file to the expected path
+ var store = new DefaultAccountStore(this.logger);
+
+ // First save a valid account to create the directory structure
+ store.SaveDefaultAccount(TestUsername, TestClientId, TestTenantId);
+
+ // Then overwrite the file with garbage
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var filePath = Path.Combine(homeDir, ".azureauth", $"default_account_{TestTenantId}_{TestClientId}.json");
+ File.WriteAllText(filePath, "not valid json!!");
+
+ var result = store.GetDefaultAccount(TestClientId, TestTenantId);
+ result.Should().BeNull();
+
+ // Clean up
+ File.Delete(filePath);
+ }
+
+ [Test]
+ public void DifferentClientTenant_AreIndependent()
+ {
+ var store = new DefaultAccountStore(this.logger);
+ var clientId2 = Guid.NewGuid();
+ var tenantId2 = Guid.NewGuid().ToString();
+
+ store.SaveDefaultAccount("user1@contoso.com", TestClientId, TestTenantId);
+ store.SaveDefaultAccount("user2@contoso.com", clientId2, tenantId2);
+
+ store.GetDefaultAccount(TestClientId, TestTenantId).Should().Be("user1@contoso.com");
+ store.GetDefaultAccount(clientId2, tenantId2).Should().Be("user2@contoso.com");
+
+ // Clean up
+ store.ClearDefaultAccount(TestClientId, TestTenantId);
+ store.ClearDefaultAccount(clientId2, tenantId2);
+ }
+ }
+}
diff --git a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs
index 24b24103..452bb9d7 100644
--- a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs
+++ b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs
@@ -41,7 +41,10 @@ public static IEnumerable Create(
// We skip CachedAuth if Broker is present in authMode on windows 10 or 11, since Broker
// already tries CachedAuth with its PCAWrapper object built using withBroker(options).
- if (!(authMode.IsBroker() && platformUtils.IsWindows10Or11()))
+ // The same applies on macOS when the broker is available.
+ // If broker is requested but unavailable, CachedAuth is still added as a first-pass attempt.
+ bool brokerWillRun = authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOSBrokerAvailable());
+ if (!brokerWillRun)
{
flows.Add(new CachedAuth(logger, authParams, preferredDomain, pcaWrapper));
}
@@ -53,12 +56,31 @@ public static IEnumerable Create(
flows.Add(new IntegratedWindowsAuthentication(logger, authParams, preferredDomain, pcaWrapper));
}
- // This check silently fails on winserver if broker has been requested.
- // Future: Consider making AuthMode platform aware at Runtime.
+ // Broker is silently skipped when unavailable on the current platform
+ // (e.g., Windows Server, macOS without Company Portal). The executor
+ // continues to the next flow in the list (Web, DeviceCode, etc.).
// https://github.com/AzureAD/microsoft-authentication-cli/issues/55
- if (authMode.IsBroker() && platformUtils.IsWindows10Or11())
+ if (authMode.IsBroker())
{
- flows.Add(new Broker(logger, authParams, preferredDomain: preferredDomain, pcaWrapper: pcaWrapper, promptHint: promptHint));
+ if (platformUtils.IsWindows10Or11())
+ {
+ flows.Add(new Broker(logger, authParams, preferredDomain: preferredDomain, pcaWrapper: pcaWrapper, promptHint: promptHint, platformUtils: platformUtils));
+ }
+ else if (platformUtils.IsMacOS())
+ {
+ if (platformUtils.IsMacOSBrokerAvailable())
+ {
+ flows.Add(new Broker(logger, authParams, preferredDomain: preferredDomain, pcaWrapper: pcaWrapper, promptHint: promptHint, platformUtils: platformUtils));
+ }
+ else
+ {
+ logger.LogWarning(
+ "Broker authentication was requested but is not available on this machine. " +
+ "macOS broker requires Company Portal version 5.2603.0 or later " +
+ $"(checked: {PlatformUtils.CompanyPortalAppPath}). " +
+ "Skipping broker and falling through to next auth flow.");
+ }
+ }
}
if (authMode.IsWeb())
diff --git a/src/MSALWrapper/AuthFlow/Broker.cs b/src/MSALWrapper/AuthFlow/Broker.cs
index 29a17fe1..4f9a7efb 100644
--- a/src/MSALWrapper/AuthFlow/Broker.cs
+++ b/src/MSALWrapper/AuthFlow/Broker.cs
@@ -5,6 +5,7 @@ namespace Microsoft.Authentication.MSALWrapper.AuthFlow
{
using System;
using System.Collections.Generic;
+ using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
@@ -12,9 +13,10 @@ namespace Microsoft.Authentication.MSALWrapper.AuthFlow
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Broker;
+ using Microsoft.Identity.Client.Utils;
///
- /// The broker auth flow.
+ /// The broker auth flow. Supports Windows (WAM) and macOS (Enterprise SSO Extension).
///
public class Broker : AuthFlowBase
{
@@ -22,6 +24,8 @@ public class Broker : AuthFlowBase
private readonly string preferredDomain;
private readonly string promptHint;
private readonly IPCAWrapper pcaWrapper;
+ private readonly AuthParameters authParameters;
+ private readonly IPlatformUtils platformUtils;
///
/// The interactive auth timeout.
@@ -36,15 +40,19 @@ public class Broker : AuthFlowBase
/// The preferred domain.
/// Optional: IPCAWrapper to use.
/// The customized header text in account picker for WAM prompts.
- public Broker(ILogger logger, AuthParameters authParameters, string preferredDomain = null, IPCAWrapper pcaWrapper = null, string promptHint = null)
+ /// Optional: IPlatformUtils for platform detection (defaults to runtime detection).
+ public Broker(ILogger logger, AuthParameters authParameters, string preferredDomain = null, IPCAWrapper pcaWrapper = null, string promptHint = null, IPlatformUtils platformUtils = null)
{
this.logger = logger;
+ this.authParameters = authParameters;
this.scopes = authParameters.Scopes;
this.preferredDomain = preferredDomain;
this.promptHint = promptHint;
+ this.platformUtils = platformUtils ?? new PlatformUtils(logger);
this.pcaWrapper = pcaWrapper ?? this.BuildPCAWrapper(authParameters.Client, authParameters.Tenant);
}
+#if PlatformWindows
private enum GetAncestorType
{
///
@@ -62,6 +70,7 @@ private enum GetAncestorType
///
GetRootOwner = 3,
}
+#endif
///
protected override string Name { get; } = Constants.AuthFlow.Broker;
@@ -69,8 +78,7 @@ private enum GetAncestorType
///
protected override async Task GetTokenInnerAsync()
{
- IAccount account = await this.pcaWrapper.TryToGetCachedAccountAsync(this.preferredDomain)
- ?? PublicClientApplication.OperatingSystemAccount;
+ IAccount account = await this.ResolveAccountAsync();
TokenResult tokenResult = await CachedAuth.GetTokenAsync(
this.logger,
@@ -105,46 +113,192 @@ protected override async Task GetTokenInnerAsync()
this.GetTokenInteractiveWithClaims(ex.Claims),
this.errors).ConfigureAwait(false);
}
+ catch (Exception ex) when (this.platformUtils.IsMacOS())
+ {
+ // On macOS, broker interactive auth can fail (e.g., Company Portal not installed,
+ // SSO Extension not running, or main thread issue). Fall back to browser-based auth.
+ this.errors.Add(ex);
+ this.logger.LogDebug($"macOS broker interactive auth failed: {ex.Message}. Falling back to browser auth.");
+
+ tokenResult = await this.FallbackToBrowserAuthAsync(account);
+ }
+
+ // Persist the resolved account username for future silent auth on macOS.
+ if (tokenResult != null && this.platformUtils.IsMacOS())
+ {
+ this.PersistDefaultAccount(tokenResult);
+ }
return tokenResult;
}
+ ///
+ /// Resolves the account to use for token acquisition.
+ /// On Windows, falls back to OperatingSystemAccount if no cached account.
+ /// On macOS, uses the persisted default account username to look up from cache.
+ ///
+ private async Task ResolveAccountAsync()
+ {
+ // First, try the MSAL cache filtered by preferred domain.
+ IAccount account = await this.pcaWrapper.TryToGetCachedAccountAsync(this.preferredDomain);
+ if (account != null)
+ {
+ return account;
+ }
+
+ if (this.platformUtils.IsMacOS())
+ {
+ // On macOS, OperatingSystemAccount is not supported. Instead, look up
+ // the persisted default account username from a previous successful auth.
+ var store = new DefaultAccountStore(this.logger);
+ var persistedUsername = store.GetDefaultAccount(this.authParameters.Client, this.authParameters.Tenant);
+
+ if (!string.IsNullOrEmpty(persistedUsername))
+ {
+ this.logger.LogDebug($"Looking up persisted account '{persistedUsername}' from MSAL cache");
+ var accounts = await this.pcaWrapper.TryToGetCachedAccountsAsync();
+ account = accounts?.FirstOrDefault(a =>
+ a.Username.Equals(persistedUsername, StringComparison.OrdinalIgnoreCase));
+
+ if (account != null)
+ {
+ return account;
+ }
+
+ this.logger.LogDebug("Persisted account not found in MSAL cache, will use interactive auth");
+ }
+
+ // No cached or persisted account — will trigger interactive auth
+ return null;
+ }
+
+ // On Windows, fall back to OperatingSystemAccount sentinel for WAM resolution.
+ return PublicClientApplication.OperatingSystemAccount;
+ }
+
+ ///
+ /// Persists the authenticated account username for future macOS silent auth.
+ ///
+ private void PersistDefaultAccount(TokenResult tokenResult)
+ {
+ try
+ {
+ // Get the account username from the MSAL cache (the token result
+ // itself doesn't carry the username, but the cache was just updated).
+ var task = this.pcaWrapper.TryToGetCachedAccountAsync(this.preferredDomain);
+ task.Wait();
+ var resolvedAccount = task.Result;
+ if (resolvedAccount != null && !string.IsNullOrEmpty(resolvedAccount.Username))
+ {
+ var store = new DefaultAccountStore(this.logger);
+ store.SaveDefaultAccount(resolvedAccount.Username, this.authParameters.Client, this.authParameters.Tenant);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug($"Failed to persist default account after auth: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Falls back to browser-based interactive auth on macOS when the broker fails.
+ /// Creates a separate PCA without broker configuration using http://localhost redirect.
+ ///
+ private async Task FallbackToBrowserAuthAsync(IAccount account)
+ {
+ this.logger.LogDebug("Creating browser fallback PCA for macOS");
+
+ var fallbackBuilder = PublicClientApplicationBuilder
+ .Create($"{this.authParameters.Client}")
+ .WithAuthority($"https://login.microsoftonline.com/{this.authParameters.Tenant}")
+ .WithRedirectUri(Constants.AadRedirectUri.ToString())
+ .WithLogging(
+ this.LogMSAL,
+ Identity.Client.LogLevel.Verbose,
+ enablePiiLogging: false,
+ enableDefaultPlatformLogging: true);
+
+ var fallbackPca = new PCAWrapper(this.logger, fallbackBuilder.Build(), this.errors, this.authParameters.Tenant);
+
+ return await TaskExecutor.CompleteWithin(
+ this.logger,
+ this.interactiveAuthTimeout,
+ $"{this.Name} browser fallback",
+ (CancellationToken cancellationToken) => fallbackPca
+ .WithPromptHint(this.promptHint)
+ .GetTokenInteractiveAsync(this.scopes, account, cancellationToken),
+ this.errors).ConfigureAwait(false);
+ }
+
+#if PlatformWindows
[DllImport("kernel32.dll")]
private static extern IntPtr GetConsoleWindow();
+#endif
private Func> GetTokenInteractive(IAccount account)
{
- return (CancellationToken cancellationToken) => this.pcaWrapper
- .WithPromptHint(this.promptHint)
- .GetTokenInteractiveAsync(this.scopes, account, cancellationToken);
+ return async (CancellationToken cancellationToken) =>
+ {
+ if (this.platformUtils.IsMacOS() && MacMainThreadScheduler.Instance().IsRunning())
+ {
+ TokenResult result = null;
+ await MacMainThreadScheduler.Instance().RunOnMainThreadAsync(async () =>
+ {
+ result = await this.pcaWrapper
+ .WithPromptHint(this.promptHint)
+ .GetTokenInteractiveAsync(this.scopes, account, cancellationToken);
+ });
+ return result;
+ }
+
+ return await this.pcaWrapper
+ .WithPromptHint(this.promptHint)
+ .GetTokenInteractiveAsync(this.scopes, account, cancellationToken);
+ };
}
private Func> GetTokenInteractiveWithClaims(string claims)
{
- return (CancellationToken cancellationToken) => this.pcaWrapper
- .WithPromptHint(this.promptHint)
- .GetTokenInteractiveAsync(this.scopes, claims, cancellationToken);
+ return async (CancellationToken cancellationToken) =>
+ {
+ if (this.platformUtils.IsMacOS() && MacMainThreadScheduler.Instance().IsRunning())
+ {
+ TokenResult result = null;
+ await MacMainThreadScheduler.Instance().RunOnMainThreadAsync(async () =>
+ {
+ result = await this.pcaWrapper
+ .WithPromptHint(this.promptHint)
+ .GetTokenInteractiveAsync(this.scopes, claims, cancellationToken);
+ });
+ return result;
+ }
+
+ return await this.pcaWrapper
+ .WithPromptHint(this.promptHint)
+ .GetTokenInteractiveAsync(this.scopes, claims, cancellationToken);
+ };
}
+#if PlatformWindows
///
/// Retrieves the handle to the ancestor of the specified window.
///
/// A handle to the window whose ancestor is to be retrieved.
/// If this parameter is the desktop window, the function returns NULL.
/// The ancestor to be retrieved.
- /// The return value is the handle to the ancestor window.[DllImport("user32.dll", ExactSpelling = true)]
+ /// The return value is the handle to the ancestor window.
[DllImport("user32.dll", ExactSpelling = true)]
#pragma warning disable SA1204 // Static elements should appear before instance elements
private static extern IntPtr GetAncestor(IntPtr windowsHandle, GetAncestorType flags);
#pragma warning restore SA1204 // Static elements should appear before instance elements
- // MSAL will be providing a similar helper in the future that we can use to simplify this(AzureAD/microsoft-authentication-library-for-dotnet#3590).
private IntPtr GetParentWindowHandle()
{
IntPtr consoleHandle = GetConsoleWindow();
IntPtr ancestorHandle = GetAncestor(consoleHandle, GetAncestorType.GetRootOwner);
return ancestorHandle;
}
+#endif
private IPCAWrapper BuildPCAWrapper(Guid clientId, string tenantId)
{
@@ -156,12 +310,25 @@ private IPCAWrapper BuildPCAWrapper(Guid clientId, string tenantId)
this.LogMSAL,
Identity.Client.LogLevel.Verbose,
enablePiiLogging: false,
- enableDefaultPlatformLogging: true)
- .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
- {
- Title = this.promptHint,
- })
- .WithParentActivityOrWindow(() => this.GetParentWindowHandle()); // Pass parent window handle to MSAL so it can parent the authentication dialogs.
+ enableDefaultPlatformLogging: true);
+
+ if (this.platformUtils.IsMacOS())
+ {
+ clientBuilder
+ .WithRedirectUri(Constants.MacOSBrokerRedirectUri.ToString())
+ .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.OSX));
+ }
+ else
+ {
+#if PlatformWindows
+ clientBuilder
+ .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows)
+ {
+ Title = this.promptHint,
+ })
+ .WithParentActivityOrWindow(() => this.GetParentWindowHandle());
+#endif
+ }
return new PCAWrapper(this.logger, clientBuilder.Build(), this.errors, tenantId);
}
diff --git a/src/MSALWrapper/AuthMode.cs b/src/MSALWrapper/AuthMode.cs
index 546be983..93a10c56 100644
--- a/src/MSALWrapper/AuthMode.cs
+++ b/src/MSALWrapper/AuthMode.cs
@@ -49,13 +49,20 @@ public enum AuthMode : short
///
Default = Broker | Web,
#else
+ ///
+ /// Broker auth mode (macOS Enterprise SSO Extension).
+ ///
+ Broker = 1 << 2,
+
///
/// All auth modes.
///
- All = Web | DeviceCode,
+ All = Broker | Web | DeviceCode,
///
- /// Default auth mode.
+ /// Default auth mode. On macOS, broker is opt-in via --mode broker because
+ /// it requires Company Portal and apps using broker-required CA policies
+ /// will hang indefinitely if web auth is attempted as fallback.
///
Default = Web,
#endif
@@ -76,7 +83,7 @@ public static bool IsBroker(this AuthMode authMode)
#if PlatformWindows
return (AuthMode.Broker & authMode) == AuthMode.Broker;
#else
- return false;
+ return (AuthMode.Broker & authMode) == AuthMode.Broker;
#endif
}
diff --git a/src/MSALWrapper/Constants.cs b/src/MSALWrapper/Constants.cs
index 7ced6cf4..82446042 100644
--- a/src/MSALWrapper/Constants.cs
+++ b/src/MSALWrapper/Constants.cs
@@ -22,6 +22,11 @@ public static class Constants
///
public static readonly Uri AadRedirectUri = new Uri("http://localhost");
+ ///
+ /// The redirect uri for macOS brokered authentication (unsigned runtime apps).
+ ///
+ public static readonly Uri MacOSBrokerRedirectUri = new Uri("msauth.com.msauth.unsignedapp://auth");
+
///
/// The name of an environment variable used to disable file cache configuration.
///
diff --git a/src/MSALWrapper/DefaultAccountStore.cs b/src/MSALWrapper/DefaultAccountStore.cs
new file mode 100644
index 00000000..ef2c3c76
--- /dev/null
+++ b/src/MSALWrapper/DefaultAccountStore.cs
@@ -0,0 +1,124 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Authentication.MSALWrapper
+{
+ using System;
+ using System.IO;
+ using System.Text.Json;
+
+ using Microsoft.Extensions.Logging;
+
+ ///
+ /// Persists the last successfully authenticated account username so that
+ /// subsequent runs can look up the specific IAccount from the MSAL cache
+ /// instead of relying on the OperatingSystemAccount sentinel (which is
+ /// not supported on macOS).
+ ///
+ public class DefaultAccountStore
+ {
+ private const string AccountDir = ".azureauth";
+ private readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger.
+ public DefaultAccountStore(ILogger logger)
+ {
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Saves the default account username for a given client and tenant.
+ ///
+ /// The account username (email) to persist.
+ /// The client ID.
+ /// The tenant ID.
+ public void SaveDefaultAccount(string username, Guid clientId, string tenantId)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ return;
+ }
+
+ try
+ {
+ var filePath = this.GetFilePath(clientId, tenantId);
+ var dir = Path.GetDirectoryName(filePath);
+ if (!Directory.Exists(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ var json = JsonSerializer.Serialize(new DefaultAccountData { Username = username });
+ File.WriteAllText(filePath, json);
+ this.logger.LogDebug($"Persisted default account for tenant {tenantId}");
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug($"Failed to persist default account: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Gets the persisted default account username for a given client and tenant.
+ ///
+ /// The client ID.
+ /// The tenant ID.
+ /// The persisted username, or null if not found or on error.
+ public string GetDefaultAccount(Guid clientId, string tenantId)
+ {
+ try
+ {
+ var filePath = this.GetFilePath(clientId, tenantId);
+ if (!File.Exists(filePath))
+ {
+ return null;
+ }
+
+ var json = File.ReadAllText(filePath);
+ var data = JsonSerializer.Deserialize(json);
+ return data?.Username;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug($"Failed to read default account: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Clears the persisted default account for a given client and tenant.
+ ///
+ /// The client ID.
+ /// The tenant ID.
+ public void ClearDefaultAccount(Guid clientId, string tenantId)
+ {
+ try
+ {
+ var filePath = this.GetFilePath(clientId, tenantId);
+ if (File.Exists(filePath))
+ {
+ File.Delete(filePath);
+ this.logger.LogDebug($"Cleared default account for tenant {tenantId}");
+ }
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug($"Failed to clear default account: {ex.Message}");
+ }
+ }
+
+ private string GetFilePath(Guid clientId, string tenantId)
+ {
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(homeDir, AccountDir, $"default_account_{tenantId}_{clientId}.json");
+ }
+
+ private class DefaultAccountData
+ {
+ public string Username { get; set; }
+ }
+ }
+}
diff --git a/src/MSALWrapper/IPlatformUtils.cs b/src/MSALWrapper/IPlatformUtils.cs
index b40cb7ff..787664bb 100644
--- a/src/MSALWrapper/IPlatformUtils.cs
+++ b/src/MSALWrapper/IPlatformUtils.cs
@@ -19,5 +19,17 @@ public interface IPlatformUtils
///
/// - true if running on any version of Windows.
bool IsWindows();
+ ///
+ /// Check if running on macOS.
+ ///
+ /// - true if running on macOS.
+ bool IsMacOS();
+
+ ///
+ /// Check if macOS brokered authentication is available.
+ /// Requires macOS, Company Portal installed, and CP version >= 2603.
+ ///
+ /// - true if macOS broker prerequisites are met.
+ bool IsMacOSBrokerAvailable();
}
}
diff --git a/src/MSALWrapper/MSALWrapper.csproj b/src/MSALWrapper/MSALWrapper.csproj
index dbb8e68a..1549bc91 100644
--- a/src/MSALWrapper/MSALWrapper.csproj
+++ b/src/MSALWrapper/MSALWrapper.csproj
@@ -24,9 +24,10 @@
-
+
-
-
+
+
+
\ No newline at end of file
diff --git a/src/MSALWrapper/PlatformUtils.cs b/src/MSALWrapper/PlatformUtils.cs
index be9d502e..978ec1b1 100644
--- a/src/MSALWrapper/PlatformUtils.cs
+++ b/src/MSALWrapper/PlatformUtils.cs
@@ -4,6 +4,8 @@
namespace Microsoft.Authentication.MSALWrapper
{
using System;
+ using System.Diagnostics;
+ using System.IO;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
@@ -15,6 +17,8 @@ public class PlatformUtils : IPlatformUtils
private ILogger logger;
private Lazy isWindows;
private Lazy isWindows10;
+ private Lazy isMacOS;
+ private Lazy isMacOSBrokerAvailable;
///
/// Initializes a new instance of the class.
@@ -25,6 +29,8 @@ public PlatformUtils(ILogger logger)
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.isWindows = new Lazy(() => this.CheckWindows());
this.isWindows10 = new Lazy(() => this.CheckWindows10());
+ this.isMacOS = new Lazy(() => this.CheckMacOS());
+ this.isMacOSBrokerAvailable = new Lazy(() => this.CheckMacOSBrokerAvailable());
}
///
@@ -39,6 +45,179 @@ public bool IsWindows()
return this.isWindows.Value;
}
+ ///
+ public bool IsMacOS()
+ {
+ return this.isMacOS.Value;
+ }
+
+ ///
+ public bool IsMacOSBrokerAvailable()
+ {
+ return this.isMacOSBrokerAvailable.Value;
+ }
+
+ private bool CheckMacOS()
+ {
+ this.logger.LogTrace($"IsMacOS: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) = {RuntimeInformation.IsOSPlatform(OSPlatform.OSX)}");
+ return RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
+ }
+
+ ///
+ /// Minimum Company Portal release number required for unsigned app broker support.
+ /// CP 2603 added redirect_uri validation fix for msauth.com.msauth.unsignedapp://auth.
+ ///
+ private const int MinimumCPRelease = 2603;
+
+ ///
+ /// Path where Company Portal is expected to be installed on macOS.
+ ///
+ public const string CompanyPortalAppPath = "/Applications/Company Portal.app";
+
+ private bool CheckMacOSBrokerAvailable()
+ {
+ if (!this.IsMacOS())
+ {
+ return false;
+ }
+
+ this.logger.LogTrace($"Checking for Company Portal at: {CompanyPortalAppPath}");
+
+ if (!Directory.Exists(CompanyPortalAppPath))
+ {
+ this.logger.LogDebug($"macOS broker unavailable: Company Portal not found at {CompanyPortalAppPath}");
+ return false;
+ }
+
+ this.logger.LogTrace($"Company Portal found at: {CompanyPortalAppPath}");
+
+ try
+ {
+ var plistPath = $"{CompanyPortalAppPath}/Contents/Info";
+ var psi = new ProcessStartInfo
+ {
+ FileName = "defaults",
+ Arguments = $"read \"{plistPath}\" CFBundleShortVersionString",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ this.logger.LogTrace($"Reading CP version: defaults read \"{plistPath}\" CFBundleShortVersionString");
+
+ using var process = Process.Start(psi);
+ var output = process.StandardOutput.ReadToEnd().Trim();
+ var stderr = process.StandardError.ReadToEnd().Trim();
+ process.WaitForExit(5000);
+
+ this.logger.LogTrace($"CP version raw output: '{output}'");
+ if (!string.IsNullOrEmpty(stderr))
+ {
+ this.logger.LogTrace($"CP version stderr: '{stderr}'");
+ }
+
+ // Version format: "5.RRRR.B" where RRRR is the release number (e.g., 2603)
+ var parts = output.Split('.');
+ if (parts.Length >= 2 && int.TryParse(parts[1], out int releaseNumber))
+ {
+ var meetsMinimum = releaseNumber >= MinimumCPRelease;
+ this.logger.LogDebug($"Company Portal version: {output} (release {releaseNumber}), minimum required: {MinimumCPRelease}, meets minimum: {meetsMinimum}");
+ this.logger.LogTrace($"Company Portal path: {CompanyPortalAppPath}");
+ this.logger.LogTrace($"Company Portal version parts: major={parts[0]}, release={parts[1]}{(parts.Length >= 3 ? $", build={parts[2]}" : string.Empty)}");
+
+ if (!meetsMinimum)
+ {
+ this.logger.LogWarning($"macOS broker unavailable: Company Portal {output} (at {CompanyPortalAppPath}) is below minimum required release {MinimumCPRelease}.");
+ return false;
+ }
+
+ // TODO: Re-enable once we confirm SSO Extension registration is required.
+ // Check if the Enterprise SSO Extension is registered via MDM.
+ // This is a soft check — disable by setting AZUREAUTH_SKIP_SSO_CHECK=1
+ // if it proves unnecessary (e.g., broker works without it in some configs).
+ // if (!this.IsSSOExtensionRegistered())
+ // {
+ // return false;
+ // }
+
+ return true;
+ }
+
+ this.logger.LogDebug($"macOS broker: unable to parse Company Portal version '{output}' from {CompanyPortalAppPath}");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogDebug($"macOS broker: failed to check Company Portal version at {CompanyPortalAppPath}: {ex.Message}");
+ this.logger.LogTrace($"macOS broker: version check exception: {ex}");
+ return false;
+ }
+ }
+
+ ///
+ /// Checks if the macOS Enterprise SSO Extension is registered via MDM.
+ /// Uses `app-sso -l` which returns a plist array of registered extensions.
+ /// An empty array means no SSO extensions are configured.
+ ///
+ /// To skip this check (e.g., if broker works without it), set AZUREAUTH_SKIP_SSO_CHECK=1.
+ ///
+ private bool IsSSOExtensionRegistered()
+ {
+ if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AZUREAUTH_SKIP_SSO_CHECK")))
+ {
+ this.logger.LogDebug("SSO Extension check skipped (AZUREAUTH_SKIP_SSO_CHECK is set)");
+ return true;
+ }
+
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "app-sso",
+ Arguments = "-l",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ this.logger.LogTrace("Checking SSO Extension registration: app-sso -l");
+
+ using var process = Process.Start(psi);
+ var output = process.StandardOutput.ReadToEnd().Trim();
+ process.WaitForExit(5000);
+
+ this.logger.LogTrace($"app-sso output: '{output}'");
+
+ // An empty plist array looks like: or \n
+ bool hasExtensions = !string.IsNullOrEmpty(output)
+ && !output.Contains("")
+ && !output.Contains("\n");
+
+ if (!hasExtensions)
+ {
+ this.logger.LogWarning(
+ "macOS broker unavailable: No Enterprise SSO Extensions registered. " +
+ "Your MDM profile may not have been applied yet. " +
+ "Try restarting or contact your IT admin. " +
+ "Set AZUREAUTH_SKIP_SSO_CHECK=1 to bypass this check.");
+ }
+ else
+ {
+ this.logger.LogDebug("Enterprise SSO Extension is registered");
+ }
+
+ return hasExtensions;
+ }
+ catch (Exception ex)
+ {
+ // If app-sso isn't available or fails, don't block — assume it might work.
+ this.logger.LogDebug($"SSO Extension check failed (non-fatal, proceeding): {ex.Message}");
+ return true;
+ }
+ }
+
private bool CheckWindows()
{
this.logger.LogTrace($"IsWindows: RuntimeInformation.IsOSPlatform(OSPlatform.Windows) = {RuntimeInformation.IsOSPlatform(OSPlatform.Windows)}");