diff --git a/.azuredevops/BuildAndTest.yml b/.azuredevops/BuildAndTest.yml index 8d70848..77047d4 100644 --- a/.azuredevops/BuildAndTest.yml +++ b/.azuredevops/BuildAndTest.yml @@ -6,32 +6,64 @@ parameters: name: Azure-Pipelines-1ESPT-ExDShared image: windows-latest os: windows + runtime: win-x64 - pool: name: Azure-Pipelines-1ESPT-ExDShared image: ubuntu-latest os: linux + runtime: linux-x64 - pool: name: Azure Pipelines image: macOS-latest os: macOS + runtime: osx-x64 + - pool: + name: Azure-Pipelines-1ESPT-ExDShared + image: windows-latest + os: windows + runtime: win-arm64 + archiveExt: zip + - pool: + name: Azure-Pipelines-1ESPT-ExDShared + image: ubuntu-latest + os: linux + runtime: linux-arm64 + archiveExt: tar.gz + - pool: + name: Azure Pipelines + image: macOS-latest + os: macOS + runtime: osx-arm64 + archiveExt: tar.gz stages: - stage: build displayName: Build And Test jobs: - ${{ each config in parameters.buildConfigs }}: - - job: build_${{ config.pool.os }} - displayName: Building and Testing on ${{ config.pool.os }} + - job: build_${{ replace(config.runtime, '-', '_') }} + displayName: Building and Testing on ${{ config.runtime }} pool: name: ${{ config.pool.name }} image: ${{ config.pool.image }} os: ${{ config.pool.os }} + templateContext: + outputs: + - output: pipelineArtifact + targetPath: dist/${{ config.runtime }} + artifactName: azureauth-${{ config.runtime }} steps: - checkout: self - task: UseDotNet@2 displayName: Use .NET Core sdk 8.x inputs: version: 8.x + - task: NuGetToolInstaller@0 + displayName: Use NuGet 6.x + inputs: + versionSpec: 6.x + - task: NuGetAuthenticate@1 + displayName: Authenticate to Azure Artifacts - task: DotNetCoreCLI@2 displayName: Install dependencies inputs: @@ -39,6 +71,7 @@ stages: feedsToUse: select vstsFeed: Office includeNuGetOrg: false + arguments: --runtime ${{ config.runtime }} # 1ES PT requires explicit build task for Roslyn analysis. Auto-injected Roslyn task will use build logs from this build. - task: DotNetCoreCLI@2 displayName: Build projects @@ -50,4 +83,13 @@ stages: displayName: Test inputs: command: test - arguments: --no-restore --no-build --verbosity normal \ No newline at end of file + arguments: --no-restore --no-build --verbosity normal + - task: DotNetCoreCLI@2 + displayName: Publish artifacts + inputs: + command: publish + projects: src/AzureAuth/AzureAuth.csproj + arguments: --configuration release --self-contained true --runtime ${{ config.runtime }} --output dist/${{ config.runtime }} + publishWebProjects: false + zipAfterPublish: false + modifyOutputPath: true \ No newline at end of file diff --git a/src/AzureAuth/Commands/Ado/CommandPat.cs b/src/AzureAuth/Commands/Ado/CommandPat.cs index 0d361b1..ee48069 100644 --- a/src/AzureAuth/Commands/Ado/CommandPat.cs +++ b/src/AzureAuth/Commands/Ado/CommandPat.cs @@ -155,7 +155,7 @@ public int OnExecute(ILogger logger, IPublicClientAuth publicClientA var pat = manager.GetPatAsync(this.PatOptions()).Result; // Do not use logger to avoid printing PATs into log files. - Console.Write(FormatPat(pat, this.Output)); + Console.WriteLine(FormatPat(pat, this.Output)); } return 0; @@ -275,4 +275,4 @@ private IPatCache Cache() return new PatCache(storageWrapper); } } -} +} \ No newline at end of file diff --git a/src/AzureAuth/Commands/Ado/CommandToken.cs b/src/AzureAuth/Commands/Ado/CommandToken.cs index 47b02fb..463bad7 100644 --- a/src/AzureAuth/Commands/Ado/CommandToken.cs +++ b/src/AzureAuth/Commands/Ado/CommandToken.cs @@ -122,7 +122,7 @@ public int OnExecute(ILogger logger, IEnv env, ITelemetryService t } // Do not use logger to avoid printing tokens into log files. - Console.Write(FormatToken(token.Token, this.Output, Authorization.Bearer)); + Console.WriteLine(FormatToken(token.Token, this.Output, Authorization.Bearer)); return 0; } } diff --git a/src/AzureAuth/Commands/CommandAad.cs b/src/AzureAuth/Commands/CommandAad.cs index 96b1ed0..575fce5 100644 --- a/src/AzureAuth/Commands/CommandAad.cs +++ b/src/AzureAuth/Commands/CommandAad.cs @@ -405,7 +405,7 @@ private int GetToken(IPublicClientAuth publicClientAuth) this.logger.LogSuccess(tokenResult.ToString()); break; case OutputMode.Token: - Console.Write(tokenResult.Token); + Console.WriteLine(tokenResult.Token); break; case OutputMode.Json: Console.Write(tokenResult.ToJson()); diff --git a/src/MSALWrapper.Test/PCACacheTest.cs b/src/MSALWrapper.Test/PCACacheTest.cs new file mode 100644 index 0000000..1fe419c --- /dev/null +++ b/src/MSALWrapper.Test/PCACacheTest.cs @@ -0,0 +1,697 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Authentication.MSALWrapper.Test +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.InteropServices; + using System.Runtime.Versioning; + using Microsoft.Extensions.Logging; + using Microsoft.Identity.Client; + using Microsoft.Identity.Client.Extensions.Msal; + using Moq; + using FluentAssertions; + using NUnit.Framework; + + /// + /// Tests for the PCACache class. + /// + [TestFixture] + public class PCACacheTest + { + private Mock loggerMock; + private Guid testTenantId; + private PCACache pcaCache; + + /// + /// Set up test fixtures. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock(); + this.testTenantId = Guid.NewGuid(); + this.pcaCache = new PCACache(this.loggerMock.Object, this.testTenantId); + } + + /// + /// Test that SetupTokenCache returns early when cache is disabled. + /// + [Test] + public void SetupTokenCache_CacheDisabled_ReturnsEarly() + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, "1"); + + var userTokenCacheMock = new Mock(); + var errors = new List(); + + try + { + // Act + this.pcaCache.SetupTokenCache(userTokenCacheMock.Object, errors); + + // Assert + errors.Should().BeEmpty(); + userTokenCacheMock.VerifyNoOtherCalls(); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + + /// + /// Test that LinuxHelper.IsLinux() correctly wraps RuntimeInformation.IsOSPlatform(OSPlatform.Linux). + /// + [Test] + public void LinuxHelper_IsLinux_MatchesPlatformDetection() + { + // Act + var helperResult = LinuxHelper.IsLinux(); + var expectedResult = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + // Assert + helperResult.Should().Be(expectedResult, + "LinuxHelper.IsLinux() should return the same value as RuntimeInformation.IsOSPlatform(OSPlatform.Linux)"); + } + + /// + /// Test headless Linux environment detection. + /// + [Test] + public void IsHeadlessLinux_DetectsHeadlessEnvironment() + { + // Arrange + var originalDisplay = Environment.GetEnvironmentVariable("DISPLAY"); + var originalWaylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + + try + { + // Test with no display variables set + Environment.SetEnvironmentVariable("DISPLAY", null); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", null); + + // We can't directly test the private method, but we can verify the environment variable logic + var display = Environment.GetEnvironmentVariable("DISPLAY"); + var waylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + + var isHeadless = string.IsNullOrEmpty(display) && string.IsNullOrEmpty(waylandDisplay); + + isHeadless.Should().BeTrue("Environment should be detected as headless when no display variables are set"); + + // Test with display variable set + Environment.SetEnvironmentVariable("DISPLAY", ":0"); + display = Environment.GetEnvironmentVariable("DISPLAY"); + waylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + + isHeadless = string.IsNullOrEmpty(display) && string.IsNullOrEmpty(waylandDisplay); + + isHeadless.Should().BeFalse("Environment should not be detected as headless when DISPLAY is set"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("DISPLAY", originalDisplay); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", originalWaylandDisplay); + } + } + + /// + /// Test that plain text cache directory and file are created with correct permissions. + /// + [Test] + [SupportedOSPlatform("linux")] + public void PlainTextCache_CreatesDirectoryAndFileWithCorrectPermissions() + { + // This test is only relevant on Linux platforms + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Assert.Ignore("This test is only relevant on Linux platforms"); + } + + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + var originalDisplay = Environment.GetEnvironmentVariable("DISPLAY"); + var originalWaylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var testCacheDir = Path.Combine(homeDir, ".azureauth"); + var testTenantId = Guid.NewGuid(); + var testCacheFile = Path.Combine(testCacheDir, $"msal_{testTenantId}_cache.json"); + + try + { + // Enable cache and set headless Linux environment + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, null); + Environment.SetEnvironmentVariable("DISPLAY", null); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", null); + + // Clean up any existing test cache + if (File.Exists(testCacheFile)) + { + File.Delete(testCacheFile); + } + + // Create a new PCACache instance and attempt setup + var logger = new Mock(); + var cache = new PCACache(logger.Object, testTenantId); + var userTokenCacheMock = new Mock(); + var errors = new List(); + + // Act + // This will attempt keyring cache first, fail, then fallback to plain text cache + cache.SetupTokenCache(userTokenCacheMock.Object, errors); + + // Assert + // Verify cache directory exists + Directory.Exists(testCacheDir).Should().BeTrue( + "Plain text cache directory should be created"); + + // Verify cache file exists + File.Exists(testCacheFile).Should().BeTrue( + "Plain text cache file should be created"); + + // Verify directory permissions (700 = UserRead | UserWrite | UserExecute) + var dirMode = File.GetUnixFileMode(testCacheDir); + var expectedDirMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + dirMode.Should().Be(expectedDirMode, + "Directory should have 700 permissions (user read/write/execute only)"); + + // Verify file permissions (600 = UserRead | UserWrite) + var fileMode = File.GetUnixFileMode(testCacheFile); + var expectedFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + fileMode.Should().Be(expectedFileMode, + "File should have 600 permissions (user read/write only)"); + + // Verify no group or other permissions on directory + (dirMode & UnixFileMode.GroupRead).Should().Be((UnixFileMode)0, + "Directory should not have group read permission"); + (dirMode & UnixFileMode.GroupWrite).Should().Be((UnixFileMode)0, + "Directory should not have group write permission"); + (dirMode & UnixFileMode.GroupExecute).Should().Be((UnixFileMode)0, + "Directory should not have group execute permission"); + (dirMode & UnixFileMode.OtherRead).Should().Be((UnixFileMode)0, + "Directory should not have other read permission"); + (dirMode & UnixFileMode.OtherWrite).Should().Be((UnixFileMode)0, + "Directory should not have other write permission"); + (dirMode & UnixFileMode.OtherExecute).Should().Be((UnixFileMode)0, + "Directory should not have other execute permission"); + + // Verify no group or other permissions on file + (fileMode & UnixFileMode.GroupRead).Should().Be((UnixFileMode)0, + "File should not have group read permission"); + (fileMode & UnixFileMode.GroupWrite).Should().Be((UnixFileMode)0, + "File should not have group write permission"); + (fileMode & UnixFileMode.OtherRead).Should().Be((UnixFileMode)0, + "File should not have other read permission"); + (fileMode & UnixFileMode.OtherWrite).Should().Be((UnixFileMode)0, + "File should not have other write permission"); + + // Verify file content is valid JSON + var fileContent = File.ReadAllText(testCacheFile); + fileContent.Should().NotBeNullOrEmpty("Cache file should have content"); + + // Verify logger was called to log the plain text cache setup + logger.Verify( + x => x.Log( + Microsoft.Extensions.Logging.LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Plain text cache")), + It.IsAny(), + It.Is>((v, t) => true)), + Times.AtLeastOnce, + "Logger should log plain text cache setup"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + Environment.SetEnvironmentVariable("DISPLAY", originalDisplay); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", originalWaylandDisplay); + } + } + + /// + /// Test constructor initializes fields correctly. + /// + [Test] + public void Constructor_InitializesFieldsCorrectly() + { + // Arrange + var logger = new Mock().Object; + var tenantId = Guid.NewGuid(); + + // Act + var cache = new PCACache(logger, tenantId); + + // Assert + cache.Should().NotBeNull(); + } + + /// + /// Test constructor with different tenant IDs creates different cache instances. + /// + [Test] + public void Constructor_WithDifferentTenantIds_CreatesDifferentInstances() + { + // Arrange + var logger = new Mock().Object; + var tenantId1 = Guid.NewGuid(); + var tenantId2 = Guid.NewGuid(); + + // Act + var cache1 = new PCACache(logger, tenantId1); + var cache2 = new PCACache(logger, tenantId2); + + // Assert + cache1.Should().NotBeNull(); + cache2.Should().NotBeNull(); + cache1.Should().NotBeSameAs(cache2); + } + + /// + /// Test that SetupTokenCache with null token cache does not throw when cache is disabled. + /// + [Test] + public void SetupTokenCache_WithNullTokenCache_CacheDisabled_DoesNotThrow() + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, "1"); + var errors = new List(); + + try + { + // Act & Assert + // When cache is disabled, method returns early and doesn't use the token cache + Assert.DoesNotThrow(() => + this.pcaCache.SetupTokenCache(null, errors)); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + + /// + /// Test that SetupTokenCache handles null errors list gracefully when cache is disabled. + /// + [Test] + public void SetupTokenCache_WithNullErrorsList_CacheDisabled_HandlesGracefully() + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, "1"); + var userTokenCacheMock = new Mock(); + + try + { + // Act & Assert + // When cache is disabled, null errors list should not cause issues + Assert.DoesNotThrow(() => + this.pcaCache.SetupTokenCache(userTokenCacheMock.Object, null)); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + + /// + /// Test that SetupTokenCache with cache enabled attempts to set up cache. + /// + [Test] + public void SetupTokenCache_CacheEnabled_AttemptsSetup() + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, null); + + var userTokenCacheMock = new Mock(); + var errors = new List(); + + try + { + // Act + this.pcaCache.SetupTokenCache(userTokenCacheMock.Object, errors); + + // Assert + // On non-Linux systems or systems with keyring support, this should succeed or add errors + // The test verifies that the method executes without throwing unhandled exceptions + Assert.Pass("SetupTokenCache executed successfully or added errors to the list"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + + /// + /// Test that SetupTokenCache with cache disabled does not modify errors list. + /// + [Test] + public void SetupTokenCache_CacheDisabled_DoesNotModifyErrorsList() + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, "true"); + + var userTokenCacheMock = new Mock(); + var errors = new List(); + + try + { + // Act + this.pcaCache.SetupTokenCache(userTokenCacheMock.Object, errors); + + // Assert + errors.Should().BeEmpty("Cache is disabled, so no errors should be added"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + + /// + /// Test that environment variable check is case-sensitive for cache disable. + /// + [Test] + public void SetupTokenCache_CacheDisableVariableEmpty_DoesNotDisableCache() + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, string.Empty); + + var userTokenCacheMock = new Mock(); + var errors = new List(); + + try + { + // Act + this.pcaCache.SetupTokenCache(userTokenCacheMock.Object, errors); + + // Assert + // Empty string should not disable cache (only null or whitespace) + Assert.Pass("SetupTokenCache executed with empty cache disable variable"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + + /// + /// Test IsHeadlessLinux with WAYLAND_DISPLAY set. + /// + [Test] + public void IsHeadlessLinux_WithWaylandDisplaySet_ReturnsFalse() + { + // Arrange + var originalDisplay = Environment.GetEnvironmentVariable("DISPLAY"); + var originalWaylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + + try + { + // Test with only WAYLAND_DISPLAY set + Environment.SetEnvironmentVariable("DISPLAY", null); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", "wayland-0"); + + // Act + var isHeadless = LinuxHelper.IsHeadlessLinux(); + + // Assert + isHeadless.Should().BeFalse("Environment should not be headless when WAYLAND_DISPLAY is set"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("DISPLAY", originalDisplay); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", originalWaylandDisplay); + } + } + + /// + /// Test IsHeadlessLinux with both display variables set. + /// + [Test] + public void IsHeadlessLinux_WithBothDisplayVariablesSet_ReturnsFalse() + { + // Arrange + var originalDisplay = Environment.GetEnvironmentVariable("DISPLAY"); + var originalWaylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + + try + { + // Test with both display variables set + Environment.SetEnvironmentVariable("DISPLAY", ":0"); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", "wayland-0"); + + // Act + var isHeadless = LinuxHelper.IsHeadlessLinux(); + + // Assert + isHeadless.Should().BeFalse("Environment should not be headless when both display variables are set"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("DISPLAY", originalDisplay); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", originalWaylandDisplay); + } + } + + /// + /// Test that cache file name and plain text cache file name are different. + /// + [Test] + public void CacheFileName_DifferentFromPlainTextCacheFileName() + { + // Arrange + var cacheFileName = $"msal_{this.testTenantId}.cache"; + var plainTextCacheFileName = $"msal_{this.testTenantId}_cache.json"; + + // Assert + cacheFileName.Should().NotBe(plainTextCacheFileName, + "Cache file name and plain text cache file name should be different"); + } + + /// + /// Test Logger is invoked when SetupTokenCache encounters errors. + /// + [Test] + public void SetupTokenCache_OnError_LogsWarning() + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, null); + + var loggerMock = new Mock(); + var userTokenCacheMock = new Mock(); + var errors = new List(); + var cache = new PCACache(loggerMock.Object, Guid.NewGuid()); + + try + { + // Act + cache.SetupTokenCache(userTokenCacheMock.Object, errors); + + // Assert + // Verify that if errors occurred, logging was attempted + if (errors.Count > 0) + { + loggerMock.Verify( + x => x.Log( + Microsoft.Extensions.Logging.LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => true), + It.IsAny(), + It.Is>((v, t) => true)), + Times.AtLeastOnce); + } + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + + /// + /// Test cache directory uses LocalApplicationData on Windows. + /// + [Test] + public void CacheDirectory_UsesLocalApplicationData() + { + // Arrange + var expectedAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + // Assert + expectedAppData.Should().NotBeNullOrEmpty("LocalApplicationData folder should be available"); + + var expectedPath = Path.Combine(expectedAppData, ".IdentityService"); + expectedPath.Should().NotBeNullOrEmpty(); + } + + /// + /// Test that multiple instances with same tenant ID use same cache file name. + /// + [Test] + public void MultipleInstances_SameTenantId_UseSameCacheFileName() + { + // Arrange + var logger = new Mock().Object; + var tenantId = Guid.NewGuid(); + + // Act + var cache1 = new PCACache(logger, tenantId); + var cache2 = new PCACache(logger, tenantId); + + // Assert + // Both instances should be configured to use the same cache file name pattern + var expectedCacheFileName = $"msal_{tenantId}.cache"; + expectedCacheFileName.Should().Contain(tenantId.ToString()); + } + + /// + /// Test LinuxHelper.IsLinux returns consistent result. + /// + [Test] + public void LinuxHelper_IsLinux_ReturnsConsistentResult() + { + // Act + var result1 = LinuxHelper.IsLinux(); + var result2 = LinuxHelper.IsLinux(); + + // Assert + result1.Should().Be(result2, "IsLinux should return consistent results"); + result1.Should().Be(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)); + } + + /// + /// Test LinuxHelper.IsHeadlessLinux with empty string display variables. + /// + [Test] + public void LinuxHelper_IsHeadlessLinux_WithEmptyDisplayVariables_ReturnsTrue() + { + // Arrange + var originalDisplay = Environment.GetEnvironmentVariable("DISPLAY"); + var originalWaylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + + try + { + Environment.SetEnvironmentVariable("DISPLAY", string.Empty); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", string.Empty); + + // Act + var result = LinuxHelper.IsHeadlessLinux(); + + // Assert + result.Should().BeTrue("Empty display variables should indicate headless environment"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("DISPLAY", originalDisplay); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", originalWaylandDisplay); + } + } + + /// + /// Test LinuxHelper.IsHeadlessLinux returns false when DISPLAY is set. + /// + [Test] + public void LinuxHelper_IsHeadlessLinux_WithDisplaySet_ReturnsFalse() + { + // Arrange + var originalDisplay = Environment.GetEnvironmentVariable("DISPLAY"); + var originalWaylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + + try + { + Environment.SetEnvironmentVariable("DISPLAY", ":0"); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", null); + + // Act + var result = LinuxHelper.IsHeadlessLinux(); + + // Assert + result.Should().BeFalse("DISPLAY variable set should indicate non-headless environment"); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("DISPLAY", originalDisplay); + Environment.SetEnvironmentVariable("WAYLAND_DISPLAY", originalWaylandDisplay); + } + } + + /// + /// Test that Guid.Empty is valid for tenant ID. + /// + [Test] + public void Constructor_WithEmptyGuid_CreatesInstance() + { + // Arrange + var logger = new Mock().Object; + var emptyGuid = Guid.Empty; + + // Act + var cache = new PCACache(logger, emptyGuid); + + // Assert + cache.Should().NotBeNull("PCACache should accept Guid.Empty as tenant ID"); + } + + /// + /// Test cache setup with various cache disable variable values. + /// + [TestCase("1", true)] + [TestCase("true", true)] + [TestCase("True", true)] + [TestCase("yes", true)] + [TestCase("0", true)] + [TestCase("false", true)] + [TestCase(null, false)] + public void SetupTokenCache_WithVariousCacheDisableValues_BehavesCorrectly(string envValue, bool shouldSkipSetup) + { + // Arrange + var originalEnvVar = Environment.GetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE); + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, envValue); + + var userTokenCacheMock = new Mock(); + var errors = new List(); + + try + { + // Act + this.pcaCache.SetupTokenCache(userTokenCacheMock.Object, errors); + + // Assert + if (shouldSkipSetup) + { + errors.Should().BeEmpty("When cache is disabled, no errors should be added"); + } + else + { + // When cache is enabled, setup is attempted + Assert.Pass("Cache setup was attempted"); + } + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(Constants.OEAUTH_MSAL_DISABLE_CACHE, originalEnvVar); + } + } + } +} diff --git a/src/MSALWrapper/LinuxHelper.cs b/src/MSALWrapper/LinuxHelper.cs new file mode 100644 index 0000000..c40d2cd --- /dev/null +++ b/src/MSALWrapper/LinuxHelper.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Authentication.MSALWrapper +{ + /// + /// Provides helper methods for Linux-specific functionality in the MSAL wrapper. + /// + public static class LinuxHelper + { + /// + /// Checks if the current platform is Linux. + /// + /// True if running on Linux, false otherwise. + public static bool IsLinux() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + } + + /// + /// Checks if the current Linux environment is headless (no display server). + /// + /// True if headless Linux environment, false otherwise. + public static bool IsHeadlessLinux() + { + // Check if DISPLAY and WAYLAND_DISPLAY environment variables are not set or empty + var display = Environment.GetEnvironmentVariable("DISPLAY"); + var waylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"); + if (string.IsNullOrEmpty(display) && string.IsNullOrEmpty(waylandDisplay)) + { + return true; + } + + return false; + } + + /// + /// Sets directory permissions to user only (700) on Unix systems. + /// + /// The directory path to set permissions for. + /// logging directory permission information + [SupportedOSPlatform("linux")] + public static void SetDirectoryPermissions(string directoryPath, ILogger logger) + { + if (!IsLinux()) + { + return; + } + + try + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + File.SetUnixFileMode(directoryPath, mode); + } + catch (Exception ex) + { + logger.LogWarning($"Failed to set directory permissions for '{directoryPath}': {ex.Message}"); + } + } + + /// + /// Sets file permissions to user only (600) on Unix systems. + /// + /// The file path to set permissions for. + /// logging file information permission + [SupportedOSPlatform("linux")] + public static void SetFilePermissions(string filePath, ILogger logger) + { + if (!IsLinux()) + { + return; + } + + try + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(filePath, mode); + } + catch (Exception ex) + { + logger.LogWarning($"Failed to set file permissions for '{filePath}': {ex.Message}"); + } + } + } +} diff --git a/src/MSALWrapper/PCACache.cs b/src/MSALWrapper/PCACache.cs index ac4629f..b15dddb 100644 --- a/src/MSALWrapper/PCACache.cs +++ b/src/MSALWrapper/PCACache.cs @@ -1,14 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Authentication.MSALWrapper.Test")] namespace Microsoft.Authentication.MSALWrapper { - using System; - using System.Collections.Generic; - using System.IO; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.InteropServices; /// /// The PCA cache class. @@ -27,6 +31,10 @@ internal class PCACache private static KeyValuePair linuxKeyRingAttr1 = new KeyValuePair("Version", "1"); private static KeyValuePair linuxKeyRingAttr2 = new KeyValuePair("ProductGroup", "Microsoft Develoepr Tools"); + // Plain text cache fallback for headless Linux + private const string PlainTextCacheDir = ".azureauth"; + private readonly string plainTextCacheFileName; + private readonly ILogger logger; private readonly string osxKeyChainSuffix; @@ -45,6 +53,7 @@ internal PCACache(ILogger logger, Guid tenantId) this.cacheFileName = $"msal_{tenantId}.cache"; this.cacheDir = this.GetCacheServiceFolder(); + this.plainTextCacheFileName = $"msal_{tenantId}_cache.json"; } /// @@ -77,6 +86,13 @@ public void SetupTokenCache(ITokenCache userTokenCache, IList errors) { this.logger.LogWarning($"MSAL token cache verification failed.\n{ex.Message}\n"); errors.Add(ex); + + // On Linux, if keyring fails and we're in a headless environment, try plain text fallback + if (LinuxHelper.IsLinux() && LinuxHelper.IsHeadlessLinux()) + { + this.logger.LogDebug("Attempting plain text cache fallback for headless Linux environment."); + this.SetupPlainTextCache(userTokenCache, errors); + } } catch (AggregateException ex) when (ex.InnerException.Message.Contains("Could not get access to the shared lock file")) { @@ -88,6 +104,58 @@ public void SetupTokenCache(ITokenCache userTokenCache, IList errors) } } + /// + /// Sets up a plain text cache fallback for headless Linux environments. + /// + /// An to use. + /// The errors list to append error encountered to. + + private void SetupPlainTextCache(ITokenCache userTokenCache, IList errors) + { + try + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var cacheDir = Path.Combine(homeDir, PlainTextCacheDir); + var cacheFilePath = Path.Combine(cacheDir, this.plainTextCacheFileName); + + // Create directory if it doesn't exist +#pragma warning disable CA1416 + if (!Directory.Exists(cacheDir)) + { + Directory.CreateDirectory(cacheDir); + // Set directory permissions to user only (700) + LinuxHelper.SetDirectoryPermissions(cacheDir, logger); + } + + // Create or ensure cache file exists with proper permissions + if (!File.Exists(cacheFilePath)) + { + File.WriteAllText(cacheFilePath, "{}"); + LinuxHelper.SetFilePermissions(cacheFilePath, logger); + } + else + { + // Ensure existing file has proper permissions + LinuxHelper.SetFilePermissions(cacheFilePath, logger); + } +#pragma warning restore CA1416 + + var storageProperties = new StorageCreationPropertiesBuilder(this.plainTextCacheFileName, cacheDir) + .WithUnprotectedFile() + .Build(); + + MsalCacheHelper cacher = MsalCacheHelper.CreateAsync(storageProperties).Result; + cacher.RegisterCache(userTokenCache); + + this.logger.LogDebug($"Plain text cache fallback configured at: {cacheFilePath}"); + } + catch (Exception ex) + { + this.logger.LogWarning($"Plain text cache fallback failed: {ex.Message}"); + errors.Add(ex); + } + } + /// /// Gets the absolute path of the cache folder. Only available on Windows. ///