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:// ![WAM redirect URI configuration](wam-config.png "A screenshot of a WAM URI being configured as a custom redirect URI.") +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)}");