From 59b5328d1ac70e374e73e3a634c3e1fefca6fb6b Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Mon, 6 Apr 2026 16:30:57 -0700 Subject: [PATCH 01/11] feat: add macOS brokered authentication support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for macOS Enterprise SSO Extension brokered authentication, bringing feature parity with Windows WAM broker support. Key changes: - Upgrade MSAL 4.65.0 → 4.83.1, add NativeInterop v0.20.3 - Extend Broker auth flow with macOS-specific PCA config, account resolution, and browser fallback - Add DefaultAccountStore to persist account username for silent auth (OperatingSystemAccount not supported on macOS) - Add MacMainThreadScheduler message loop in Program.cs - Extend AuthMode enum and CLI parsing to support broker on macOS - Update docs with macOS broker prerequisites and redirect URI config Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/usage.md | 22 +- src/AdoPat/AdoPat.csproj | 2 +- src/AzureAuth.Test/AuthModeExtensionsTest.cs | 18 +- src/AzureAuth/AuthModeExtensions.cs | 9 +- src/AzureAuth/Commands/CommandAad.cs | 4 +- src/AzureAuth/Program.cs | 30 +++ .../MSALWrapper.Benchmark.csproj | 4 +- .../AuthFlow/AuthFlowFactoryTest.cs | 17 +- .../AuthFlow/BrokerMacOSTest.cs | 215 ++++++++++++++++++ src/MSALWrapper.Test/AuthFlow/BrokerTest.cs | 12 +- src/MSALWrapper.Test/AuthModeTest.cs | 7 +- .../DefaultAccountStoreTest.cs | 167 ++++++++++++++ src/MSALWrapper/AuthFlow/AuthFlowFactory.cs | 7 +- src/MSALWrapper/AuthFlow/Broker.cs | 160 ++++++++++++- src/MSALWrapper/AuthMode.cs | 11 +- src/MSALWrapper/Constants.cs | 5 + src/MSALWrapper/DefaultAccountStore.cs | 124 ++++++++++ src/MSALWrapper/IPlatformUtils.cs | 5 + src/MSALWrapper/MSALWrapper.csproj | 7 +- src/MSALWrapper/PlatformUtils.cs | 14 ++ 21 files changed, 801 insertions(+), 41 deletions(-) create mode 100644 src/MSALWrapper.Test/AuthFlow/BrokerMacOSTest.cs create mode 100644 src/MSALWrapper.Test/DefaultAccountStoreTest.cs create mode 100644 src/MSALWrapper/DefaultAccountStore.cs 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/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..e1928949 100644 --- a/src/AzureAuth.Test/AuthModeExtensionsTest.cs +++ b/src/AzureAuth.Test/AuthModeExtensionsTest.cs @@ -58,13 +58,14 @@ 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 }; + var subject = new[] { AuthMode.Broker, AuthMode.Web }; // Act + Assert subject.Combine().PreventInteractionIfNeeded(this.envMock.Object, this.logger).Should().Be(AuthMode.Default); @@ -73,7 +74,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 +84,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..ccaf07d3 100644 --- a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs +++ b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs @@ -243,6 +243,9 @@ public void AllModes_Windows10Or11() [Platform("MacOsX")] public void AllModes_Mac() { + this.MockIsMacOS(true); + this.MockIsWindows10Or11(false); + IEnumerable subject = this.Subject(AuthMode.All); this.pcaWrapperMock.VerifyAll(); @@ -252,7 +255,7 @@ public void AllModes_Mac() .Should() .BeEquivalentTo(new[] { - typeof(CachedAuth), + typeof(Broker), typeof(Web), typeof(DeviceCode), }); @@ -262,8 +265,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. + this.MockIsMacOS(true); + this.MockIsWindows10Or11(false); + var subject = this.Subject(AuthMode.Default); this.platformUtilsMock.VerifyAll(); @@ -272,7 +276,7 @@ public void DefaultModes_Not_Windows() .Select(a => a.GetType()) .Should() .ContainInOrder( - typeof(CachedAuth), + typeof(Broker), typeof(Web)); } @@ -285,5 +289,10 @@ 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); + } } } 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..cbf9470d 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) @@ -112,7 +113,7 @@ public void NonWindowsDefaultModes() { var subject = AuthMode.Default; subject.IsIWA().Should().BeFalse(); - subject.IsBroker().Should().BeFalse(); + subject.IsBroker().Should().BeTrue(); subject.IsWeb().Should().BeTrue(); subject.IsDeviceCode().Should().BeFalse(); } 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..8c61fa9e 100644 --- a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs +++ b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs @@ -41,7 +41,8 @@ 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 where the broker handles its own silent attempt. + if (!(authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOS()))) { flows.Add(new CachedAuth(logger, authParams, preferredDomain, pcaWrapper)); } @@ -56,9 +57,9 @@ public static IEnumerable Create( // This check silently fails on winserver if broker has been requested. // Future: Consider making AuthMode platform aware at Runtime. // https://github.com/AzureAD/microsoft-authentication-cli/issues/55 - if (authMode.IsBroker() && platformUtils.IsWindows10Or11()) + if (authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOS())) { - flows.Add(new Broker(logger, authParams, preferredDomain: preferredDomain, pcaWrapper: pcaWrapper, promptHint: promptHint)); + flows.Add(new Broker(logger, authParams, preferredDomain: preferredDomain, pcaWrapper: pcaWrapper, promptHint: promptHint, platformUtils: platformUtils)); } if (authMode.IsWeb()) diff --git a/src/MSALWrapper/AuthFlow/Broker.cs b/src/MSALWrapper/AuthFlow/Broker.cs index 29a17fe1..428c8aaf 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; @@ -14,7 +15,7 @@ namespace Microsoft.Authentication.MSALWrapper.AuthFlow using Microsoft.Identity.Client.Broker; /// - /// The broker auth flow. + /// The broker auth flow. Supports Windows (WAM) and macOS (Enterprise SSO Extension). /// public class Broker : AuthFlowBase { @@ -22,6 +23,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 +39,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 +69,7 @@ private enum GetAncestorType /// GetRootOwner = 3, } +#endif /// protected override string Name { get; } = Constants.AuthFlow.Broker; @@ -69,8 +77,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,12 +112,127 @@ 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) { @@ -126,25 +248,26 @@ private Func> GetTokenInteractiveWithClaims .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 +279,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..1cb750a7 100644 --- a/src/MSALWrapper/AuthMode.cs +++ b/src/MSALWrapper/AuthMode.cs @@ -49,15 +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 = Web, + Default = Broker | Web, #endif } @@ -76,7 +81,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..b8c2a8ea 100644 --- a/src/MSALWrapper/IPlatformUtils.cs +++ b/src/MSALWrapper/IPlatformUtils.cs @@ -19,5 +19,10 @@ 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(); } } 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..ca346340 100644 --- a/src/MSALWrapper/PlatformUtils.cs +++ b/src/MSALWrapper/PlatformUtils.cs @@ -15,6 +15,7 @@ public class PlatformUtils : IPlatformUtils private ILogger logger; private Lazy isWindows; private Lazy isWindows10; + private Lazy isMacOS; /// /// Initializes a new instance of the class. @@ -25,6 +26,7 @@ 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()); } /// @@ -39,6 +41,18 @@ public bool IsWindows() return this.isWindows.Value; } + /// + public bool IsMacOS() + { + return this.isMacOS.Value; + } + + private bool CheckMacOS() + { + this.logger.LogTrace($"IsMacOS: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) = {RuntimeInformation.IsOSPlatform(OSPlatform.OSX)}"); + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } + private bool CheckWindows() { this.logger.LogTrace($"IsWindows: RuntimeInformation.IsOSPlatform(OSPlatform.Windows) = {RuntimeInformation.IsOSPlatform(OSPlatform.Windows)}"); From 8c266b4e16f620b5f9477c67961ad61e9d2fc686 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Tue, 7 Apr 2026 07:58:37 -0700 Subject: [PATCH 02/11] feat: add Company Portal version check before broker auth Skip macOS broker auth if Company Portal is not installed or is below version 2603 (which added redirect_uri validation fix for unsigned apps). Falls back to web auth transparently in those cases. - Add IsMacOSBrokerAvailable() to IPlatformUtils/PlatformUtils - AuthFlowFactory uses IsMacOSBrokerAvailable() as gatekeeper - Broker.cs still uses IsMacOS() for runtime behavior (fallback, persist) - Reads CP version from Info.plist via 'defaults read' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AuthFlow/AuthFlowFactoryTest.cs | 9 ++- src/MSALWrapper/AuthFlow/AuthFlowFactory.cs | 4 +- src/MSALWrapper/IPlatformUtils.cs | 7 ++ src/MSALWrapper/PlatformUtils.cs | 72 +++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs index ccaf07d3..7145dcab 100644 --- a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs +++ b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs @@ -243,7 +243,7 @@ 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); @@ -265,7 +265,7 @@ public void AllModes_Mac() [Platform("MacOsx")] public void DefaultModes_Not_Windows() { - this.MockIsMacOS(true); + this.MockIsMacOSBrokerAvailable(true); this.MockIsWindows10Or11(false); var subject = this.Subject(AuthMode.Default); @@ -294,5 +294,10 @@ 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/AuthFlow/AuthFlowFactory.cs b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs index 8c61fa9e..ce37aded 100644 --- a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs +++ b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs @@ -42,7 +42,7 @@ 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). // The same applies on macOS where the broker handles its own silent attempt. - if (!(authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOS()))) + if (!(authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOSBrokerAvailable()))) { flows.Add(new CachedAuth(logger, authParams, preferredDomain, pcaWrapper)); } @@ -57,7 +57,7 @@ public static IEnumerable Create( // This check silently fails on winserver if broker has been requested. // Future: Consider making AuthMode platform aware at Runtime. // https://github.com/AzureAD/microsoft-authentication-cli/issues/55 - if (authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOS())) + if (authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOSBrokerAvailable())) { flows.Add(new Broker(logger, authParams, preferredDomain: preferredDomain, pcaWrapper: pcaWrapper, promptHint: promptHint, platformUtils: platformUtils)); } diff --git a/src/MSALWrapper/IPlatformUtils.cs b/src/MSALWrapper/IPlatformUtils.cs index b8c2a8ea..787664bb 100644 --- a/src/MSALWrapper/IPlatformUtils.cs +++ b/src/MSALWrapper/IPlatformUtils.cs @@ -24,5 +24,12 @@ public interface IPlatformUtils /// /// - 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/PlatformUtils.cs b/src/MSALWrapper/PlatformUtils.cs index ca346340..741d590d 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; @@ -16,6 +18,7 @@ public class PlatformUtils : IPlatformUtils private Lazy isWindows; private Lazy isWindows10; private Lazy isMacOS; + private Lazy isMacOSBrokerAvailable; /// /// Initializes a new instance of the class. @@ -27,6 +30,7 @@ public PlatformUtils(ILogger 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()); } /// @@ -47,12 +51,80 @@ 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; + + private const string CompanyPortalPath = "/Applications/Company Portal.app"; + + private bool CheckMacOSBrokerAvailable() + { + if (!this.IsMacOS()) + { + return false; + } + + if (!Directory.Exists(CompanyPortalPath)) + { + this.logger.LogDebug("macOS broker unavailable: Company Portal not installed"); + return false; + } + + try + { + var psi = new ProcessStartInfo + { + FileName = "defaults", + Arguments = $"read \"{CompanyPortalPath}/Contents/Info\" CFBundleShortVersionString", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + // 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}, meets minimum ({MinimumCPRelease}): {meetsMinimum}"); + + if (!meetsMinimum) + { + this.logger.LogWarning($"macOS broker unavailable: Company Portal version {output} is below minimum required release {MinimumCPRelease}. Falling back to web auth."); + } + + return meetsMinimum; + } + + this.logger.LogDebug($"macOS broker: unable to parse Company Portal version '{output}'"); + return false; + } + catch (Exception ex) + { + this.logger.LogDebug($"macOS broker: failed to check Company Portal version: {ex.Message}"); + return false; + } + } + private bool CheckWindows() { this.logger.LogTrace($"IsWindows: RuntimeInformation.IsOSPlatform(OSPlatform.Windows) = {RuntimeInformation.IsOSPlatform(OSPlatform.Windows)}"); From c098b8604339f255cab68ef17a2df919e023890b Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Tue, 7 Apr 2026 09:35:18 -0700 Subject: [PATCH 03/11] feat: make broker opt-in on macOS, error on missing CP On macOS, broker is no longer included in default auth modes. Users must explicitly pass '--mode broker' to use it. If broker is requested but Company Portal >= 5.2603.0 is not installed, AuthFlowFactory throws a clear InvalidOperationException instead of silently falling through to web auth (which hangs for apps with broker-required CA policies like token protection). - AuthMode.Default on non-Windows: Broker|Web -> Web - AuthFlowFactory: explicit error when broker requested but CP unavailable - New test: BrokerRequested_Mac_CP_Unavailable_Throws Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/AzureAuth.Test/AuthModeExtensionsTest.cs | 3 +- .../AuthFlow/AuthFlowFactoryTest.cs | 23 +- src/MSALWrapper.Test/AuthModeTest.cs | 2 +- src/MSALWrapper/AuthFlow/AuthFlowFactory.cs | 25 +- src/MSALWrapper/AuthMode.cs | 6 +- swap-cp.sh | 48 ++++ test-macos-broker.sh | 216 ++++++++++++++++++ 7 files changed, 311 insertions(+), 12 deletions(-) create mode 100755 swap-cp.sh create mode 100755 test-macos-broker.sh diff --git a/src/AzureAuth.Test/AuthModeExtensionsTest.cs b/src/AzureAuth.Test/AuthModeExtensionsTest.cs index e1928949..6707191b 100644 --- a/src/AzureAuth.Test/AuthModeExtensionsTest.cs +++ b/src/AzureAuth.Test/AuthModeExtensionsTest.cs @@ -65,7 +65,8 @@ public void CombinedAuthMode_Allowed() 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.Broker, AuthMode.Web }; + // 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); diff --git a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs index 7145dcab..b6346f15 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,7 @@ public void AllModes_Windows10Or11() [Platform("MacOsX")] public void AllModes_Mac() { + this.MockIsMacOS(true); this.MockIsMacOSBrokerAvailable(true); this.MockIsWindows10Or11(false); @@ -265,21 +267,32 @@ public void AllModes_Mac() [Platform("MacOsx")] public void DefaultModes_Not_Windows() { - this.MockIsMacOSBrokerAvailable(true); - this.MockIsWindows10Or11(false); - + // 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()) .Should() .ContainInOrder( - typeof(Broker), + typeof(CachedAuth), typeof(Web)); } + [Test] + [Platform("MacOsX")] + public void BrokerRequested_Mac_CP_Unavailable_Throws() + { + this.MockIsWindows10Or11(false); + this.MockIsMacOS(true); + this.MockIsMacOSBrokerAvailable(false); + + Action act = () => this.Subject(AuthMode.Broker).ToList(); + + act.Should().Throw() + .WithMessage("*Company Portal*5.2603*"); + } + private void MockIsWindows10Or11(bool value) { this.platformUtilsMock.Setup(p => p.IsWindows10Or11()).Returns(value); diff --git a/src/MSALWrapper.Test/AuthModeTest.cs b/src/MSALWrapper.Test/AuthModeTest.cs index cbf9470d..073530e9 100644 --- a/src/MSALWrapper.Test/AuthModeTest.cs +++ b/src/MSALWrapper.Test/AuthModeTest.cs @@ -113,7 +113,7 @@ public void NonWindowsDefaultModes() { var subject = AuthMode.Default; subject.IsIWA().Should().BeFalse(); - subject.IsBroker().Should().BeTrue(); + subject.IsBroker().Should().BeFalse(); subject.IsWeb().Should().BeTrue(); subject.IsDeviceCode().Should().BeFalse(); } diff --git a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs index ce37aded..5451e4fd 100644 --- a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs +++ b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs @@ -42,7 +42,9 @@ 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). // The same applies on macOS where the broker handles its own silent attempt. - if (!(authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOSBrokerAvailable()))) + // Note: If broker is requested on macOS but unavailable, we throw before reaching here. + bool brokerWillRun = authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOSBrokerAvailable()); + if (!brokerWillRun) { flows.Add(new CachedAuth(logger, authParams, preferredDomain, pcaWrapper)); } @@ -57,9 +59,26 @@ public static IEnumerable Create( // This check silently fails on winserver if broker has been requested. // Future: Consider making AuthMode platform aware at Runtime. // https://github.com/AzureAD/microsoft-authentication-cli/issues/55 - if (authMode.IsBroker() && (platformUtils.IsWindows10Or11() || platformUtils.IsMacOSBrokerAvailable())) + if (authMode.IsBroker()) { - flows.Add(new Broker(logger, authParams, preferredDomain: preferredDomain, pcaWrapper: pcaWrapper, promptHint: promptHint, platformUtils: platformUtils)); + 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 + { + throw new InvalidOperationException( + "Broker authentication was requested but is not available on this machine. " + + "macOS broker requires Company Portal version 5.2603.0 or later. " + + "Please install or update Company Portal, then try again."); + } + } } if (authMode.IsWeb()) diff --git a/src/MSALWrapper/AuthMode.cs b/src/MSALWrapper/AuthMode.cs index 1cb750a7..93a10c56 100644 --- a/src/MSALWrapper/AuthMode.cs +++ b/src/MSALWrapper/AuthMode.cs @@ -60,9 +60,11 @@ public enum AuthMode : short 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 = Broker | Web, + Default = Web, #endif } diff --git a/swap-cp.sh b/swap-cp.sh new file mode 100755 index 00000000..4ce0595f --- /dev/null +++ b/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/test-macos-broker.sh b/test-macos-broker.sh new file mode 100755 index 00000000..619d0a55 --- /dev/null +++ b/test-macos-broker.sh @@ -0,0 +1,216 @@ +#!/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 +# +# Each interactive test has a timeout (default 30s). 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:-30}" + +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++)) + else + echo "❌ FAIL: $name (exit=$exit_code, expected=$expected)" + ((FAIL++)) + 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: Web flow (baseline) ─────────────────────────────── +header "Test 1: Web flow — baseline auth (interactive, opens browser)" +echo "This will try to open a browser for sign-in." +echo "If the app requires broker, this will hang — Ctrl+C or wait for timeout." +run_test "Web flow baseline" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --mode web --output json --verbosity debug + +# ── Test 2: Default modes (broker,web) ──────────────────────── +header "Test 2: Default modes — broker + web (broker skipped if CP < 2603)" +echo "🔍 Watch for: 'Company Portal version' or 'broker' log lines" +run_test "Default modes (broker+web)" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --output json --verbosity debug + +# ── Test 3: Broker-only mode ────────────────────────────────── +header "Test 3: Broker-only mode" +if [ "$BROKER_AVAILABLE" = true ]; then + echo "CP >= 2603 detected — this will attempt real broker auth" + EXPECTED_EXIT=0 +else + echo "CP < 2603 — broker gated off. Expecting failure (no available auth flows)" + EXPECTED_EXIT=1 +fi +run_test "Broker-only mode" "$EXPECTED_EXIT" \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --mode broker --output json --verbosity debug + +# ── Test 4: Explicit scopes ─────────────────────────────────── +header "Test 4: Explicit Graph scopes (Mail.Read + Chat.Read)" +echo "Opens browser for consent to specific scopes." +run_test "Explicit scopes" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --scope "https://graph.microsoft.com/Mail.Read" \ + --scope "https://graph.microsoft.com/Chat.Read" \ + --mode web --output token --verbosity debug + +# ── Test 5: Silent re-auth (cached token) ───────────────────── +header "Test 5: Silent re-auth (should use cached token, no browser)" +echo "Running same command as Test 1 — should succeed silently from cache" +run_test "Silent re-auth (cached)" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --mode web --output json --verbosity debug + +# ── Test 6: Clear cache ─────────────────────────────────────── +header "Test 6: Clear token cache" +run_test "Cache clear" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --clear --verbosity debug + +# ── 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 From 0ed4bec6b6354660d67191c43fb2fbb145282bb3 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Tue, 7 Apr 2026 09:59:56 -0700 Subject: [PATCH 04/11] improve: CP diagnostics logging and error messages - Include CP path in broker unavailable error message - Add trace-level logging: CP path, raw version output, stderr, parsed version parts (major/release/build) - Expose CompanyPortalAppPath as public const for error messages - Update test script to reflect broker-opt-in behavior: new tests for broker+web combined, trace diagnostics, reordered Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MSALWrapper/AuthFlow/AuthFlowFactory.cs | 3 +- src/MSALWrapper/PlatformUtils.cs | 36 +++++++-- test-macos-broker.sh | 82 ++++++++++++++------- 3 files changed, 87 insertions(+), 34 deletions(-) diff --git a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs index 5451e4fd..ef380ef0 100644 --- a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs +++ b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs @@ -75,7 +75,8 @@ public static IEnumerable Create( { throw new InvalidOperationException( "Broker authentication was requested but is not available on this machine. " + - "macOS broker requires Company Portal version 5.2603.0 or later. " + + "macOS broker requires Company Portal version 5.2603.0 or later " + + $"(checked: {PlatformUtils.CompanyPortalAppPath}). " + "Please install or update Company Portal, then try again."); } } diff --git a/src/MSALWrapper/PlatformUtils.cs b/src/MSALWrapper/PlatformUtils.cs index 741d590d..9d9dae6d 100644 --- a/src/MSALWrapper/PlatformUtils.cs +++ b/src/MSALWrapper/PlatformUtils.cs @@ -69,7 +69,10 @@ private bool CheckMacOS() /// private const int MinimumCPRelease = 2603; - private const string CompanyPortalPath = "/Applications/Company Portal.app"; + /// + /// Path where Company Portal is expected to be installed on macOS. + /// + public const string CompanyPortalAppPath = "/Applications/Company Portal.app"; private bool CheckMacOSBrokerAvailable() { @@ -78,49 +81,66 @@ private bool CheckMacOSBrokerAvailable() return false; } - if (!Directory.Exists(CompanyPortalPath)) + this.logger.LogTrace($"Checking for Company Portal at: {CompanyPortalAppPath}"); + + if (!Directory.Exists(CompanyPortalAppPath)) { - this.logger.LogDebug("macOS broker unavailable: Company Portal not installed"); + 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 \"{CompanyPortalPath}/Contents/Info\" CFBundleShortVersionString", + 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}, meets minimum ({MinimumCPRelease}): {meetsMinimum}"); + 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 version {output} is below minimum required release {MinimumCPRelease}. Falling back to web auth."); + this.logger.LogWarning($"macOS broker unavailable: Company Portal {output} (at {CompanyPortalAppPath}) is below minimum required release {MinimumCPRelease}."); } return meetsMinimum; } - this.logger.LogDebug($"macOS broker: unable to parse Company Portal version '{output}'"); + 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: {ex.Message}"); + 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; } } diff --git a/test-macos-broker.sh b/test-macos-broker.sh index 619d0a55..77a0c99a 100755 --- a/test-macos-broker.sh +++ b/test-macos-broker.sh @@ -142,56 +142,88 @@ else BROKER_AVAILABLE=false fi -# ── Test 1: Web flow (baseline) ─────────────────────────────── -header "Test 1: Web flow — baseline auth (interactive, opens browser)" -echo "This will try to open a browser for sign-in." -echo "If the app requires broker, this will hang — Ctrl+C or wait for timeout." -run_test "Web flow baseline" 0 \ - aad --client "$CLIENT" --tenant "$TENANT" \ - --resource "$RESOURCE" \ - --mode web --output json --verbosity debug - -# ── Test 2: Default modes (broker,web) ──────────────────────── -header "Test 2: Default modes — broker + web (broker skipped if CP < 2603)" -echo "🔍 Watch for: 'Company Portal version' or 'broker' log lines" -run_test "Default modes (broker+web)" 0 \ +# ── Test 1: Default modes — web only (broker is opt-in on macOS) ── +header "Test 1: Default modes — web only on macOS" +echo "Default mode no longer includes broker. This tests web auth flow." +echo "If the app requires broker (token protection), web will hang — Ctrl+C." +run_test "Default modes (web only)" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ --output json --verbosity debug -# ── Test 3: Broker-only mode ────────────────────────────────── -header "Test 3: Broker-only mode" +# ── Test 2: Broker-only mode (opt-in) ──────────────────────── +header "Test 2: 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 — broker gated off. Expecting failure (no available auth flows)" + 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 mode" "$EXPECTED_EXIT" \ +run_test "Broker-only (opt-in)" "$EXPECTED_EXIT" \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ --mode broker --output json --verbosity debug -# ── Test 4: Explicit scopes ─────────────────────────────────── -header "Test 4: Explicit Graph scopes (Mail.Read + Chat.Read)" -echo "Opens browser for consent to specific scopes." -run_test "Explicit scopes" 0 \ +# ── Test 3: Broker + web combined (explicit) ────────────────── +header "Test 3: 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 < 2603 — broker requested but unavailable, expecting error" + echo "(Error occurs before web is attempted because broker was explicitly requested)" + EXPECTED_EXIT=1 +fi +run_test "Broker + web combined" "$EXPECTED_EXIT" \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --mode broker --mode web --output json --verbosity debug + +# ── Test 4: Web-only explicit (for apps that support it) ────── +header "Test 4: Web-only explicit (--mode web)" +echo "Explicit web flow. For broker-required apps, this will hang." +echo "For apps supporting web auth, this should open browser and succeed." +run_test "Web-only explicit" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --mode web --output json --verbosity debug + +# ── Test 5: Trace verbosity — verify CP diagnostics in logs ─── +header "Test 5: 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 6: Explicit scopes (web) ───────────────────────────── +header "Test 6: Explicit Graph scopes (Mail.Read + Chat.Read, web)" +echo "Tests scope-based auth via web flow." +run_test "Explicit scopes (web)" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --scope "https://graph.microsoft.com/Mail.Read" \ --scope "https://graph.microsoft.com/Chat.Read" \ --mode web --output token --verbosity debug -# ── Test 5: Silent re-auth (cached token) ───────────────────── -header "Test 5: Silent re-auth (should use cached token, no browser)" +# ── Test 7: Silent re-auth (cached token) ───────────────────── +header "Test 7: Silent re-auth (should use cached token, no browser)" echo "Running same command as Test 1 — should succeed silently from cache" run_test "Silent re-auth (cached)" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ --mode web --output json --verbosity debug -# ── Test 6: Clear cache ─────────────────────────────────────── -header "Test 6: Clear token cache" +# ── Test 8: Clear cache ─────────────────────────────────────── +header "Test 8: Clear token cache" run_test "Cache clear" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ From 35467ff3c53e4954a429614efb6327792dd4e429 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Tue, 7 Apr 2026 22:40:25 -0700 Subject: [PATCH 05/11] fix: dispatch broker interactive calls to main thread on macOS The macOS broker requires AcquireTokenInteractive to run on the main thread. Program.cs starts the MacMainThreadScheduler message loop on main and dispatches CLI work to Task.Run, but the broker interactive calls were still executing on the background thread. Now GetTokenInteractive/WithClaims dispatch through MacMainThreadScheduler.RunOnMainThreadAsync when running on macOS with the scheduler active (IsRunning check prevents deadlock in tests). Also: improved CP diagnostics trace logging and included CP path in the broker unavailable error message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MSALWrapper/AuthFlow/Broker.cs | 43 ++++++++++++++--- test-macos-broker.sh | 74 +++++++++++++++++------------- 2 files changed, 80 insertions(+), 37 deletions(-) diff --git a/src/MSALWrapper/AuthFlow/Broker.cs b/src/MSALWrapper/AuthFlow/Broker.cs index 428c8aaf..4f9a7efb 100644 --- a/src/MSALWrapper/AuthFlow/Broker.cs +++ b/src/MSALWrapper/AuthFlow/Broker.cs @@ -13,6 +13,7 @@ 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. Supports Windows (WAM) and macOS (Enterprise SSO Extension). @@ -236,16 +237,46 @@ private async Task FallbackToBrowserAuthAsync(IAccount account) 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 diff --git a/test-macos-broker.sh b/test-macos-broker.sh index 77a0c99a..2a47966a 100755 --- a/test-macos-broker.sh +++ b/test-macos-broker.sh @@ -33,10 +33,10 @@ 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=$((PASS + 1)) else echo "❌ FAIL: $name (exit=$exit_code, expected=$expected)" - ((FAIL++)) + FAIL=$((FAIL + 1)) fi } @@ -99,10 +99,10 @@ run_test() { choice="${choice:-s}" if [[ "$choice" =~ ^[fF] ]]; then echo "❌ FAIL: $test_name (interrupted, marked as fail)" - ((FAIL++)) + FAIL=$((FAIL + 1)) else echo "⏭️ SKIP: $test_name (interrupted)" - ((SKIP++)) + SKIP=$((SKIP + 1)) fi return fi @@ -142,17 +142,8 @@ else BROKER_AVAILABLE=false fi -# ── Test 1: Default modes — web only (broker is opt-in on macOS) ── -header "Test 1: Default modes — web only on macOS" -echo "Default mode no longer includes broker. This tests web auth flow." -echo "If the app requires broker (token protection), web will hang — Ctrl+C." -run_test "Default modes (web only)" 0 \ - aad --client "$CLIENT" --tenant "$TENANT" \ - --resource "$RESOURCE" \ - --output json --verbosity debug - -# ── Test 2: Broker-only mode (opt-in) ──────────────────────── -header "Test 2: Broker-only mode (--mode broker)" +# ── 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" @@ -167,8 +158,8 @@ run_test "Broker-only (opt-in)" "$EXPECTED_EXIT" \ --resource "$RESOURCE" \ --mode broker --output json --verbosity debug -# ── Test 3: Broker + web combined (explicit) ────────────────── -header "Test 3: Broker + web combined (--mode broker --mode web)" +# ── Test 2: Broker + web combined (explicit) ────────────────── +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 @@ -182,17 +173,8 @@ run_test "Broker + web combined" "$EXPECTED_EXIT" \ --resource "$RESOURCE" \ --mode broker --mode web --output json --verbosity debug -# ── Test 4: Web-only explicit (for apps that support it) ────── -header "Test 4: Web-only explicit (--mode web)" -echo "Explicit web flow. For broker-required apps, this will hang." -echo "For apps supporting web auth, this should open browser and succeed." -run_test "Web-only explicit" 0 \ - aad --client "$CLIENT" --tenant "$TENANT" \ - --resource "$RESOURCE" \ - --mode web --output json --verbosity debug - -# ── Test 5: Trace verbosity — verify CP diagnostics in logs ─── -header "Test 5: Trace verbosity — CP diagnostic logging" +# ── 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 @@ -205,8 +187,38 @@ run_test "Trace CP diagnostics" "$EXPECTED_EXIT" \ --resource "$RESOURCE" \ --mode broker --output json --verbosity trace -# ── Test 6: Explicit scopes (web) ───────────────────────────── -header "Test 6: Explicit Graph scopes (Mail.Read + Chat.Read, web)" +# ── Test 4: Clear cache ─────────────────────────────────────── +header "Test 4: Clear token cache" +run_test "Cache clear" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --clear --verbosity debug + +# ── Tests below may hang for broker-required apps (web flow) ── +header "⚠️ Remaining tests use web auth — may hang for broker-required apps" +echo "Ctrl+C or wait for timeout to skip individual tests." +echo "" + +# ── Test 5: Default modes — web only (broker is opt-in on macOS) ── +header "Test 5: Default modes — web only on macOS" +echo "Default mode no longer includes broker. This tests web auth flow." +echo "If the app requires broker (token protection), web will hang — Ctrl+C." +run_test "Default modes (web only)" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --output json --verbosity debug + +# ── Test 6: Web-only explicit (for apps that support it) ────── +header "Test 6: Web-only explicit (--mode web)" +echo "Explicit web flow. For broker-required apps, this will hang." +echo "For apps supporting web auth, this should open browser and succeed." +run_test "Web-only explicit" 0 \ + aad --client "$CLIENT" --tenant "$TENANT" \ + --resource "$RESOURCE" \ + --mode web --output json --verbosity debug + +# ── Test 7: Explicit scopes (web) ───────────────────────────── +header "Test 7: Explicit Graph scopes (Mail.Read + Chat.Read, web)" echo "Tests scope-based auth via web flow." run_test "Explicit scopes (web)" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ @@ -214,7 +226,7 @@ run_test "Explicit scopes (web)" 0 \ --scope "https://graph.microsoft.com/Chat.Read" \ --mode web --output token --verbosity debug -# ── Test 7: Silent re-auth (cached token) ───────────────────── +# ── Test 8: Silent re-auth (cached token) ───────────────────── header "Test 7: Silent re-auth (should use cached token, no browser)" echo "Running same command as Test 1 — should succeed silently from cache" run_test "Silent re-auth (cached)" 0 \ From 486aacd16902452a2df7193b31cafd1c74c4e86d Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Tue, 7 Apr 2026 22:46:45 -0700 Subject: [PATCH 06/11] feat: add SSO Extension registration pre-flight check Check 'app-sso -l' for registered Enterprise SSO Extensions before attempting broker auth. If no extensions are registered (MDM profile not applied), gives a clear error instead of a cryptic broker failure. Designed for easy revert: - Set AZUREAUTH_SKIP_SSO_CHECK=1 to bypass the check entirely - If app-sso fails or isn't available, assumes broker may work (non-fatal) - The check is a single method call in CheckMacOSBrokerAvailable() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MSALWrapper/PlatformUtils.cs | 74 +++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/MSALWrapper/PlatformUtils.cs b/src/MSALWrapper/PlatformUtils.cs index 9d9dae6d..838adf3e 100644 --- a/src/MSALWrapper/PlatformUtils.cs +++ b/src/MSALWrapper/PlatformUtils.cs @@ -129,9 +129,18 @@ private bool CheckMacOSBrokerAvailable() if (!meetsMinimum) { this.logger.LogWarning($"macOS broker unavailable: Company Portal {output} (at {CompanyPortalAppPath}) is below minimum required release {MinimumCPRelease}."); + return false; } - return meetsMinimum; + // 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}"); @@ -145,6 +154,69 @@ private bool CheckMacOSBrokerAvailable() } } + /// + /// 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)}"); From f5c85d9ddb171d8eb7af54a48108321f800c32df Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Wed, 8 Apr 2026 10:24:03 -0700 Subject: [PATCH 07/11] chore: move macOS test scripts to bin/mac/ Move test-macos-broker.sh and swap-cp.sh from repo root to bin/mac/ alongside existing macOS build scripts. Update REPO_ROOT to resolve two levels up from the new location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- swap-cp.sh => bin/mac/swap-cp.sh | 0 test-macos-broker.sh => bin/mac/test-macos-broker.sh | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename swap-cp.sh => bin/mac/swap-cp.sh (100%) rename test-macos-broker.sh => bin/mac/test-macos-broker.sh (99%) diff --git a/swap-cp.sh b/bin/mac/swap-cp.sh similarity index 100% rename from swap-cp.sh rename to bin/mac/swap-cp.sh diff --git a/test-macos-broker.sh b/bin/mac/test-macos-broker.sh similarity index 99% rename from test-macos-broker.sh rename to bin/mac/test-macos-broker.sh index 2a47966a..336330c6 100755 --- a/test-macos-broker.sh +++ b/bin/mac/test-macos-broker.sh @@ -11,12 +11,12 @@ set -euo pipefail # You can also Ctrl+C during any individual test — the script traps it # and moves on. -REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +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:-30}" +TIMEOUT="${AZUREAUTH_TEST_TIMEOUT:-120}" PASS=0 FAIL=0 From 6598a96f90ce0a7431067f64bf2ef4bbf1933520 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Wed, 8 Apr 2026 10:26:43 -0700 Subject: [PATCH 08/11] improve: configurable verbosity in test script, disable SSO check - Add AZUREAUTH_TEST_VERBOSITY env var (default: debug) to control log level across all tests (Test 3 always uses trace) - Comment out SSO Extension check (confirmed unnecessary for broker) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/mac/test-macos-broker.sh | 17 +++++++++-------- src/MSALWrapper/PlatformUtils.cs | 9 +++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/bin/mac/test-macos-broker.sh b/bin/mac/test-macos-broker.sh index 336330c6..5dc2e116 100755 --- a/bin/mac/test-macos-broker.sh +++ b/bin/mac/test-macos-broker.sh @@ -17,6 +17,7 @@ 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 @@ -156,7 +157,7 @@ fi run_test "Broker-only (opt-in)" "$EXPECTED_EXIT" \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ - --mode broker --output json --verbosity debug + --mode broker --output json --verbosity "$VERBOSITY" # ── Test 2: Broker + web combined (explicit) ────────────────── header "Test 2: Broker + web combined (--mode broker --mode web)" @@ -171,7 +172,7 @@ fi run_test "Broker + web combined" "$EXPECTED_EXIT" \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ - --mode broker --mode web --output json --verbosity debug + --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" @@ -192,7 +193,7 @@ header "Test 4: Clear token cache" run_test "Cache clear" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ - --clear --verbosity debug + --clear --verbosity "$VERBOSITY" # ── Tests below may hang for broker-required apps (web flow) ── header "⚠️ Remaining tests use web auth — may hang for broker-required apps" @@ -206,7 +207,7 @@ echo "If the app requires broker (token protection), web will hang — Ctrl+C." run_test "Default modes (web only)" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ - --output json --verbosity debug + --output json --verbosity "$VERBOSITY" # ── Test 6: Web-only explicit (for apps that support it) ────── header "Test 6: Web-only explicit (--mode web)" @@ -215,7 +216,7 @@ echo "For apps supporting web auth, this should open browser and succeed." run_test "Web-only explicit" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ - --mode web --output json --verbosity debug + --mode web --output json --verbosity "$VERBOSITY" # ── Test 7: Explicit scopes (web) ───────────────────────────── header "Test 7: Explicit Graph scopes (Mail.Read + Chat.Read, web)" @@ -224,7 +225,7 @@ run_test "Explicit scopes (web)" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --scope "https://graph.microsoft.com/Mail.Read" \ --scope "https://graph.microsoft.com/Chat.Read" \ - --mode web --output token --verbosity debug + --mode web --output token --verbosity "$VERBOSITY" # ── Test 8: Silent re-auth (cached token) ───────────────────── header "Test 7: Silent re-auth (should use cached token, no browser)" @@ -232,14 +233,14 @@ echo "Running same command as Test 1 — should succeed silently from cache" run_test "Silent re-auth (cached)" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ - --mode web --output json --verbosity debug + --mode web --output json --verbosity "$VERBOSITY" # ── Test 8: Clear cache ─────────────────────────────────────── header "Test 8: Clear token cache" run_test "Cache clear" 0 \ aad --client "$CLIENT" --tenant "$TENANT" \ --resource "$RESOURCE" \ - --clear --verbosity debug + --clear --verbosity "$VERBOSITY" # ── Summary ──────────────────────────────────────────────────── header "Results" diff --git a/src/MSALWrapper/PlatformUtils.cs b/src/MSALWrapper/PlatformUtils.cs index 838adf3e..978ec1b1 100644 --- a/src/MSALWrapper/PlatformUtils.cs +++ b/src/MSALWrapper/PlatformUtils.cs @@ -132,13 +132,14 @@ private bool CheckMacOSBrokerAvailable() 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; - } + // if (!this.IsSSOExtensionRegistered()) + // { + // return false; + // } return true; } From 0d98f66b9bfabbb5355e07264e3d198c5c32ddda Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Wed, 8 Apr 2026 11:49:05 -0700 Subject: [PATCH 09/11] docs: add usage examples to test script header Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/mac/test-macos-broker.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bin/mac/test-macos-broker.sh b/bin/mac/test-macos-broker.sh index 5dc2e116..68fe496c 100755 --- a/bin/mac/test-macos-broker.sh +++ b/bin/mac/test-macos-broker.sh @@ -4,7 +4,13 @@ set -euo pipefail # Functional test script for macOS brokered auth changes # Tests AzureAuth CLI with Work IQ's 3P Graph app registration # -# Each interactive test has a timeout (default 30s). If azureauth hangs +# 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. # From 03de4a5c7da8cf51c0e34e18ed4e2c3ff4cde47c Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Wed, 8 Apr 2026 15:31:35 -0700 Subject: [PATCH 10/11] fix: broker falls through to next auth flow when unavailable on macOS Match the existing Windows pattern where broker is silently skipped on unsupported platforms (e.g., Windows Server). On macOS, if broker is requested but Company Portal is insufficient, log a warning and continue to the next flow (Web, DeviceCode, etc.) instead of throwing. This means '--mode broker --mode web' will fall through to web when CP is unavailable, and '--mode broker' alone will try CachedAuth only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AuthFlow/AuthFlowFactoryTest.cs | 29 ++++++++++++++++--- src/MSALWrapper/AuthFlow/AuthFlowFactory.cs | 13 +++++---- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs index b6346f15..21d4ff43 100644 --- a/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs +++ b/src/MSALWrapper.Test/AuthFlow/AuthFlowFactoryTest.cs @@ -281,16 +281,37 @@ public void DefaultModes_Not_Windows() [Test] [Platform("MacOsX")] - public void BrokerRequested_Mac_CP_Unavailable_Throws() + public void BrokerRequested_Mac_CP_Unavailable_SkipsBroker() { this.MockIsWindows10Or11(false); this.MockIsMacOS(true); this.MockIsMacOSBrokerAvailable(false); - Action act = () => this.Subject(AuthMode.Broker).ToList(); + // Broker is silently skipped; only CachedAuth remains when no other modes are requested. + IEnumerable subject = this.Subject(AuthMode.Broker); - act.Should().Throw() - .WithMessage("*Company Portal*5.2603*"); + 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) diff --git a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs index ef380ef0..452bb9d7 100644 --- a/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs +++ b/src/MSALWrapper/AuthFlow/AuthFlowFactory.cs @@ -41,8 +41,8 @@ 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). - // The same applies on macOS where the broker handles its own silent attempt. - // Note: If broker is requested on macOS but unavailable, we throw before reaching here. + // 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) { @@ -56,8 +56,9 @@ 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()) { @@ -73,11 +74,11 @@ public static IEnumerable Create( } else { - throw new InvalidOperationException( + 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}). " + - "Please install or update Company Portal, then try again."); + "Skipping broker and falling through to next auth flow."); } } } From de8631c7be897e27b479d96fadba60335fd28f02 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Wed, 8 Apr 2026 15:38:55 -0700 Subject: [PATCH 11/11] test: comment out web mode tests, add broker re-prompt after clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web auth hangs for this broker-required app (token protection CA policy), so comment out Tests 2 and 5. Add cache clear + broker interactive re-prompt cycle (Tests 6-8) to verify full broker lifecycle: authenticate → clear → re-authenticate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/mac/test-macos-broker.sh | 103 ++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/bin/mac/test-macos-broker.sh b/bin/mac/test-macos-broker.sh index 68fe496c..f7369b0a 100755 --- a/bin/mac/test-macos-broker.sh +++ b/bin/mac/test-macos-broker.sh @@ -165,20 +165,23 @@ run_test "Broker-only (opt-in)" "$EXPECTED_EXIT" \ --resource "$RESOURCE" \ --mode broker --output json --verbosity "$VERBOSITY" -# ── Test 2: Broker + web combined (explicit) ────────────────── -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 < 2603 — broker requested but unavailable, expecting error" - echo "(Error occurs before web is attempted because broker was explicitly requested)" - EXPECTED_EXIT=1 -fi -run_test "Broker + web combined" "$EXPECTED_EXIT" \ - aad --client "$CLIENT" --tenant "$TENANT" \ - --resource "$RESOURCE" \ - --mode broker --mode web --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" @@ -201,49 +204,49 @@ run_test "Cache clear" 0 \ --resource "$RESOURCE" \ --clear --verbosity "$VERBOSITY" -# ── Tests below may hang for broker-required apps (web flow) ── -header "⚠️ Remaining tests use web auth — may hang for broker-required apps" -echo "Ctrl+C or wait for timeout to skip individual tests." -echo "" - -# ── Test 5: Default modes — web only (broker is opt-in on macOS) ── -header "Test 5: Default modes — web only on macOS" -echo "Default mode no longer includes broker. This tests web auth flow." -echo "If the app requires broker (token protection), web will hang — Ctrl+C." -run_test "Default modes (web only)" 0 \ - aad --client "$CLIENT" --tenant "$TENANT" \ - --resource "$RESOURCE" \ - --output json --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: Web-only explicit (for apps that support it) ────── -header "Test 6: Web-only explicit (--mode web)" -echo "Explicit web flow. For broker-required apps, this will hang." -echo "For apps supporting web auth, this should open browser and succeed." -run_test "Web-only explicit" 0 \ +# ── 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" \ - --mode web --output json --verbosity "$VERBOSITY" - -# ── Test 7: Explicit scopes (web) ───────────────────────────── -header "Test 7: Explicit Graph scopes (Mail.Read + Chat.Read, web)" -echo "Tests scope-based auth via web flow." -run_test "Explicit scopes (web)" 0 \ - aad --client "$CLIENT" --tenant "$TENANT" \ - --scope "https://graph.microsoft.com/Mail.Read" \ - --scope "https://graph.microsoft.com/Chat.Read" \ - --mode web --output token --verbosity "$VERBOSITY" + --clear --verbosity "$VERBOSITY" -# ── Test 8: Silent re-auth (cached token) ───────────────────── -header "Test 7: Silent re-auth (should use cached token, no browser)" -echo "Running same command as Test 1 — should succeed silently from cache" -run_test "Silent re-auth (cached)" 0 \ +# ── 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 web --output json --verbosity "$VERBOSITY" + --mode broker --output json --verbosity "$VERBOSITY" -# ── Test 8: Clear cache ─────────────────────────────────────── -header "Test 8: Clear token cache" -run_test "Cache clear" 0 \ +# ── 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"