diff --git a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs index 2c0b2da1be..e4448663d7 100644 --- a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs +++ b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs @@ -160,7 +160,19 @@ private static async Task GetDeviceCodeCredentialAsync(IAu TokenCachePersistenceOptions = GetTokenCachePersistenceOptions(authContext), DeviceCodeCallback = (code, cancellation) => { - GraphSession.Instance.OutputWriter.WriteObject(code.Message); + if (GraphSession.Exists) + { + try + { + GraphSession.Instance.OutputWriter.WriteObject(code.Message); + return Task.CompletedTask; + } + catch (InvalidOperationException) + { + // Fall through to console output if OutputWriter is unavailable. + } + } + Console.WriteLine(code.Message); return Task.CompletedTask; } }; @@ -272,12 +284,14 @@ public static async Task AuthenticateAsync(IAuthContext authContex return signInAuthContext; } - private static async Task SignInAsync(IAuthContext authContext, CancellationToken cancellationToken = default) + internal static async Task SignInAsync(IAuthContext authContext, CancellationToken cancellationToken = default, TokenCredential tokenCredential = null) { if (authContext is null) throw new AuthenticationException(ErrorConstants.Message.MissingAuthContext); - var tokenCredential = await GetTokenCredentialAsync(authContext, cancellationToken).ConfigureAwait(false); - var token = await tokenCredential.GetTokenAsync(new TokenRequestContext(GetScopes(authContext)), cancellationToken).ConfigureAwait(false); + tokenCredential ??= await GetTokenCredentialAsync(authContext, cancellationToken).ConfigureAwait(false); + // Use isCaeEnabled: true to match the TokenRequestContext that AzureIdentityAccessTokenProvider will use + // during API calls, ensuring MSAL caches a CAE-capable token that can be found silently later. + var token = await tokenCredential.GetTokenAsync(new TokenRequestContext(GetScopes(authContext), isCaeEnabled: true), cancellationToken).ConfigureAwait(false); JwtHelpers.DecodeJWT(token.Token, account: null, ref authContext); return authContext; } diff --git a/src/Authentication/Authentication.Test/Helpers/AuthenticationHelpersTests.cs b/src/Authentication/Authentication.Test/Helpers/AuthenticationHelpersTests.cs index 87d1ad65d3..a91eef30e3 100644 --- a/src/Authentication/Authentication.Test/Helpers/AuthenticationHelpersTests.cs +++ b/src/Authentication/Authentication.Test/Helpers/AuthenticationHelpersTests.cs @@ -1,538 +1,664 @@ -using Azure.Core; -using Azure.Identity; -using Microsoft.Graph.Authentication.Test.Mocks; -using Microsoft.Graph.PowerShell.Authentication; -using Microsoft.Graph.PowerShell.Authentication.Core.TokenCache; -using Microsoft.Graph.PowerShell.Authentication.Core.Utilities; -using System; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.Graph.Authentication.Test.Helpers -{ - public class AuthenticationHelpersTests : IDisposable - { - private readonly MockAuthRecord mockAuthRecord; - public AuthenticationHelpersTests() - { - GraphSession.Initialize(() => new GraphSession()); - GraphSession.Instance.InMemoryTokenCache = new InMemoryTokenCache(); - GraphSession.Instance.GraphOption = new GraphOption(); - mockAuthRecord = new MockAuthRecord("test"); - mockAuthRecord.SerializeToFile(); - } - - [Fact] - public async Task ShouldUseDelegateAuthProviderWhenUserAccessTokenIsProvidedAsync() - { - // Arrange - GraphSession.Instance.InMemoryTokenCache = new InMemoryTokenCache(Encoding.UTF8.GetBytes(MockConstants.DummyAccessToken)); - AuthContext userProvidedAuthContext = new AuthContext - { - AuthType = AuthenticationType.UserProvidedAccessToken, - ContextScope = ContextScope.Process - }; - - AzureIdentityAccessTokenProvider authProvider = await AuthenticationHelpers.GetAuthenticationProviderAsync(userProvidedAuthContext); - HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me"); - - // Act - var accessToken = await authProvider.GetAuthorizationTokenAsync(requestMessage.RequestUri); - - // Assert - _ = Assert.IsType(authProvider); - Assert.Equal(MockConstants.DummyAccessToken, accessToken); - Assert.Equal(GraphEnvironmentConstants.EnvironmentName.Global, userProvidedAuthContext.Environment); - - // reset static instance. - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseDeviceCodeWhenSpecifiedByUserAsync() - { - // Arrange - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.DeviceCode - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // reset static instance. - GraphSession.Reset(); - } - [Fact] - public async Task ShouldUseDeviceCodeWhenFallbackAsync() - { - // Arrange - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.DeviceCode - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // reset static instance. - GraphSession.Reset(); - } - [Fact] - public async Task ShouldUseInteractiveProviderWhenDelegatedAsync() - { - // Arrange - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // reset static instance. - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseInteractiveAuthenticationProviderWhenDelegatedContextAndClientIdIsProvidedAsync() - { - // Arrange - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - ClientId = Guid.NewGuid().ToString(), - Scopes = new string[] { "User.Read" }, - ContextScope = ContextScope.Process - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // reset static instance. - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldThrowWhenAuthContextIsNullAsync() - { - // Act - var exception = await Assert.ThrowsAsync(async () => await AuthenticationHelpers.GetTokenCredentialAsync(null, default)); - - // Assert - Assert.Equal(PowerShell.Authentication.Core.ErrorConstants.Message.MissingAuthContext, exception.Message); - - // reset - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseClientCredentialProviderWhenAppOnlyContextIsProvidedAsync() - { - // Arrange - AuthContext appOnlyAuthContext = new AuthContext - { - AuthType = AuthenticationType.AppOnly, - TokenCredentialType = TokenCredentialType.ClientCertificate, - ClientId = mockAuthRecord.ClientId, - CertificateSubjectName = "cn=dummyCert", - ContextScope = ContextScope.Process, - TenantId = mockAuthRecord.TenantId - }; - _ = CreateAndStoreSelfSignedCert(appOnlyAuthContext.CertificateSubjectName); - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // reset - DeleteSelfSignedCertByName(appOnlyAuthContext.CertificateSubjectName); - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseInMemoryCertificateWhenProvidedAsync() - { - // Arrange - var certificate = CreateSelfSignedCert("cn=inmemorycert"); - AuthContext appOnlyAuthContext = new AuthContext - { - AuthType = AuthenticationType.AppOnly, - TokenCredentialType = TokenCredentialType.ClientCertificate, - ClientId = mockAuthRecord.ClientId, - Certificate = certificate, - ContextScope = ContextScope.Process, - TenantId = mockAuthRecord.TenantId - }; - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseCertNameInsteadOfPassedInCertificateWhenBothAreSpecifiedAsync() - { - // Arrange - var dummyCertName = "CN=dummycert"; - var inMemoryCertName = "CN=inmemorycert"; - _ = CreateAndStoreSelfSignedCert(dummyCertName); - var inMemoryCertificate = CreateSelfSignedCert(inMemoryCertName); - AuthContext appOnlyAuthContext = new AuthContext - { - AuthType = AuthenticationType.AppOnly, - TokenCredentialType = TokenCredentialType.ClientCertificate, - ClientId = mockAuthRecord.ClientId, - CertificateSubjectName = dummyCertName, - Certificate = inMemoryCertificate, - ContextScope = ContextScope.Process, - TenantId = mockAuthRecord.TenantId - }; - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - //CleanUp - DeleteSelfSignedCertByName(appOnlyAuthContext.CertificateSubjectName); - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseCertThumbPrintInsteadOfPassedInCertificateWhenBothAreSpecifiedAsync() - { - // Arrange - var dummyCertName = "CN=dummycert"; - var inMemoryCertName = "CN=inmemorycert"; - var storedDummyCertificate = CreateAndStoreSelfSignedCert(dummyCertName); - var inMemoryCertificate = CreateSelfSignedCert(inMemoryCertName); - AuthContext appOnlyAuthContext = new AuthContext - { - AuthType = AuthenticationType.AppOnly, - TokenCredentialType = TokenCredentialType.ClientCertificate, - ClientId = mockAuthRecord.ClientId, - CertificateThumbprint = storedDummyCertificate.Thumbprint, - Certificate = inMemoryCertificate, - ContextScope = ContextScope.Process, - TenantId = mockAuthRecord.TenantId - }; - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - //CleanUp - DeleteSelfSignedCertByThumbprint(appOnlyAuthContext.CertificateThumbprint); - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldThrowIfNonExistentCertNameIsProvidedAsync() - { - // Arrange - var dummyCertName = "CN=NonExistingCert"; - AuthContext appOnlyAuthContext = new AuthContext - { - AuthType = AuthenticationType.AppOnly, - TokenCredentialType = TokenCredentialType.ClientCertificate, - ClientId = mockAuthRecord.ClientId, - CertificateSubjectName = dummyCertName, - ContextScope = ContextScope.Process, - TenantId = mockAuthRecord.TenantId - }; - - // Act - var exception = await Assert.ThrowsAsync(async () => await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default)); - - //Assert - Assert.Equal(string.Format(CultureInfo.InvariantCulture, PowerShell.Authentication.Core.ErrorConstants.Message.CertificateNotFound, "subject name", dummyCertName), exception.Message); - } - - [Fact] - public async Task ShouldThrowIfNullInMemoryCertIsProvidedAsync() - { - // Arrange - AuthContext appOnlyAuthContext = new AuthContext - { - AuthType = AuthenticationType.AppOnly, - TokenCredentialType = TokenCredentialType.ClientCertificate, - ClientId = mockAuthRecord.ClientId, - Certificate = null, - ContextScope = ContextScope.Process, - TenantId = mockAuthRecord.TenantId - }; - - // Act - var exception = await Assert.ThrowsAsync(async () => await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default)); - - //Assert - Assert.Equal("certificate", exception.ParamName); - } - - /// - /// Create and Store a Self Signed Certificate - /// - /// - private static X509Certificate2 CreateAndStoreSelfSignedCert(string certName, StoreLocation storeLocation = StoreLocation.CurrentUser) - { - var cert = CreateSelfSignedCert(certName); - using (var store = new X509Store(StoreName.My, storeLocation)) - { - store.Open(OpenFlags.ReadWrite); - store.Add(cert); - } - - return cert; - } - - /// - /// Create a Self Signed Certificate - /// - /// - /// - - #pragma warning disable IA5352 - private static X509Certificate2 CreateSelfSignedCert(string certName) - { - ECDsa ecdsaKey = ECDsa.Create(); - CertificateRequest certificateRequest = new CertificateRequest(certName, ecdsaKey, HashAlgorithmName.SHA256); - // We have to export cert to dummy cert since `CreateSelfSigned` creates a cert without a private key. - X509Certificate2 cert = certificateRequest.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return new X509Certificate2(cert.Export(X509ContentType.Pfx, "P@55w0rd"), "P@55w0rd", X509KeyStorageFlags.Exportable); - else - return new X509Certificate2(cert.Export(X509ContentType.Pfx, "P@55w0rd"), "P@55w0rd", X509KeyStorageFlags.PersistKeySet); - } - - private static void DeleteSelfSignedCertByName(string certificateName, StoreLocation storeLocation = StoreLocation.CurrentUser) - { - using (X509Store xStore = new X509Store(StoreName.My, storeLocation)) - { - xStore.Open(OpenFlags.ReadWrite); - - X509Certificate2Collection unexpiredCerts = xStore.Certificates - .Find(X509FindType.FindByTimeValid, DateTime.Now, false) - .Find(X509FindType.FindBySubjectDistinguishedName, certificateName, false); - - // Only return current cert. - var xCertificate = unexpiredCerts - .OfType() - .OrderByDescending(c => c.NotBefore) - .FirstOrDefault(); - - xStore.Remove(xCertificate); - } - } - private static void DeleteSelfSignedCertByThumbprint(string certificateThumbPrint, StoreLocation storeLocation = StoreLocation.CurrentUser) - { - using (X509Store xStore = new X509Store(StoreName.My, storeLocation)) - { - xStore.Open(OpenFlags.ReadWrite); - - X509Certificate2Collection unexpiredCerts = xStore.Certificates - .Find(X509FindType.FindByTimeValid, DateTime.Now, false) - .Find(X509FindType.FindByThumbprint, certificateThumbPrint, false); - - // Only return current cert. - var xCertificate = unexpiredCerts - .OfType() - .OrderByDescending(c => c.NotBefore) - .FirstOrDefault(); - - xStore.Remove(xCertificate); - } - } - - [Fact] - public async Task ShouldUseInteractiveBrowserWhenWamIsDisabledWithCustomClientIdAsync() - { - // Arrange - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = true; - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - ClientId = Guid.NewGuid().ToString(), // Custom ClientId - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.InteractiveBrowser - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // Verify that we're NOT using WAM (InteractiveBrowserCredentialBrokerOptions) by checking credential type - // On Windows, if WAM was enabled, it would use InteractiveBrowserCredentialBrokerOptions - // Since we disabled it with custom ClientId, it should use regular InteractiveBrowserCredential - - // reset static instance. - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldStillUseWamWhenDisabledWithDefaultClientIdAsync() - { - // Arrange - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = true; - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - // ClientId not set, will use default from constructor - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.InteractiveBrowser - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // On Windows with default ClientId, WAM should still be enabled even when DisableWAMForMSGraph is true - // This is verified by the credential being created with InteractiveBrowserCredentialBrokerOptions internally - - // reset static instance. - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseWamWhenNotDisabledWithDefaultClientIdAsync() - { - // Arrange - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = false; - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - // ClientId not set, will use default from constructor - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.InteractiveBrowser - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // reset static instance. - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseWamWhenNotDisabledWithCustomClientIdAsync() - { - // Arrange - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = false; - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - ClientId = Guid.NewGuid().ToString(), // Custom ClientId - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.InteractiveBrowser - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // reset static instance. - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseWamWhenNullWithDefaultClientIdAsync() - { - // Arrange - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - // ClientId not set, will use default from constructor - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.InteractiveBrowser - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // When DisableWAMForMSGraph is null (default), WAM should be enabled - // reset static instance. - GraphSession.Reset(); - } - - [Fact] - public async Task ShouldUseWamWhenNullWithCustomClientIdAsync() - { - // Arrange - GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; - AuthContext delegatedAuthContext = new AuthContext - { - AuthType = AuthenticationType.Delegated, - ClientId = Guid.NewGuid().ToString(), // Custom ClientId - Scopes = new[] { "User.Read" }, - ContextScope = ContextScope.Process, - TokenCredentialType = TokenCredentialType.InteractiveBrowser - }; - - // Act - TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); - - // Assert - _ = Assert.IsType(tokenCredential); - - // When DisableWAMForMSGraph is null (default), WAM should be enabled regardless of ClientId - // reset static instance. - GraphSession.Reset(); - } - - public void Dispose() => mockAuthRecord.DeleteCache(); - } -} +using Azure.Core; +using Azure.Identity; +using Microsoft.Graph.Authentication.Test.Mocks; +using Microsoft.Graph.PowerShell.Authentication; +using Microsoft.Graph.PowerShell.Authentication.Core.TokenCache; +using Microsoft.Graph.PowerShell.Authentication.Core.Utilities; +using System; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Graph.Authentication.Test.Helpers +{ + public class AuthenticationHelpersTests : IDisposable + { + private readonly MockAuthRecord mockAuthRecord; + public AuthenticationHelpersTests() + { + GraphSession.Initialize(() => new GraphSession()); + GraphSession.Instance.InMemoryTokenCache = new InMemoryTokenCache(); + GraphSession.Instance.GraphOption = new GraphOption(); + mockAuthRecord = new MockAuthRecord("test"); + mockAuthRecord.SerializeToFile(); + } + + [Fact] + public async Task ShouldUseDelegateAuthProviderWhenUserAccessTokenIsProvidedAsync() + { + // Arrange + GraphSession.Instance.InMemoryTokenCache = new InMemoryTokenCache(Encoding.UTF8.GetBytes(MockConstants.DummyAccessToken)); + AuthContext userProvidedAuthContext = new AuthContext + { + AuthType = AuthenticationType.UserProvidedAccessToken, + ContextScope = ContextScope.Process + }; + + AzureIdentityAccessTokenProvider authProvider = await AuthenticationHelpers.GetAuthenticationProviderAsync(userProvidedAuthContext); + HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me"); + + // Act + var accessToken = await authProvider.GetAuthorizationTokenAsync(requestMessage.RequestUri); + + // Assert + _ = Assert.IsType(authProvider); + Assert.Equal(MockConstants.DummyAccessToken, accessToken); + Assert.Equal(GraphEnvironmentConstants.EnvironmentName.Global, userProvidedAuthContext.Environment); + + // reset static instance. + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseDeviceCodeWhenSpecifiedByUserAsync() + { + // Arrange + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.DeviceCode + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // reset static instance. + GraphSession.Reset(); + } + [Fact] + public async Task ShouldUseDeviceCodeWhenFallbackAsync() + { + // Arrange + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.DeviceCode + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // reset static instance. + GraphSession.Reset(); + } + [Fact] + public async Task ShouldUseInteractiveProviderWhenDelegatedAsync() + { + // Arrange + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // reset static instance. + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseInteractiveAuthenticationProviderWhenDelegatedContextAndClientIdIsProvidedAsync() + { + // Arrange + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + ClientId = Guid.NewGuid().ToString(), + Scopes = new string[] { "User.Read" }, + ContextScope = ContextScope.Process + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // reset static instance. + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldThrowWhenAuthContextIsNullAsync() + { + // Act + var exception = await Assert.ThrowsAsync(async () => await AuthenticationHelpers.GetTokenCredentialAsync(null, default)); + + // Assert + Assert.Equal(PowerShell.Authentication.Core.ErrorConstants.Message.MissingAuthContext, exception.Message); + + // reset + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseClientCredentialProviderWhenAppOnlyContextIsProvidedAsync() + { + // Arrange + AuthContext appOnlyAuthContext = new AuthContext + { + AuthType = AuthenticationType.AppOnly, + TokenCredentialType = TokenCredentialType.ClientCertificate, + ClientId = mockAuthRecord.ClientId, + CertificateSubjectName = "cn=dummyCert", + ContextScope = ContextScope.Process, + TenantId = mockAuthRecord.TenantId + }; + _ = CreateAndStoreSelfSignedCert(appOnlyAuthContext.CertificateSubjectName); + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // reset + DeleteSelfSignedCertByName(appOnlyAuthContext.CertificateSubjectName); + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseInMemoryCertificateWhenProvidedAsync() + { + // Arrange + var certificate = CreateSelfSignedCert("cn=inmemorycert"); + AuthContext appOnlyAuthContext = new AuthContext + { + AuthType = AuthenticationType.AppOnly, + TokenCredentialType = TokenCredentialType.ClientCertificate, + ClientId = mockAuthRecord.ClientId, + Certificate = certificate, + ContextScope = ContextScope.Process, + TenantId = mockAuthRecord.TenantId + }; + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseCertNameInsteadOfPassedInCertificateWhenBothAreSpecifiedAsync() + { + // Arrange + var dummyCertName = "CN=dummycert"; + var inMemoryCertName = "CN=inmemorycert"; + _ = CreateAndStoreSelfSignedCert(dummyCertName); + var inMemoryCertificate = CreateSelfSignedCert(inMemoryCertName); + AuthContext appOnlyAuthContext = new AuthContext + { + AuthType = AuthenticationType.AppOnly, + TokenCredentialType = TokenCredentialType.ClientCertificate, + ClientId = mockAuthRecord.ClientId, + CertificateSubjectName = dummyCertName, + Certificate = inMemoryCertificate, + ContextScope = ContextScope.Process, + TenantId = mockAuthRecord.TenantId + }; + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + //CleanUp + DeleteSelfSignedCertByName(appOnlyAuthContext.CertificateSubjectName); + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseCertThumbPrintInsteadOfPassedInCertificateWhenBothAreSpecifiedAsync() + { + // Arrange + var dummyCertName = "CN=dummycert"; + var inMemoryCertName = "CN=inmemorycert"; + var storedDummyCertificate = CreateAndStoreSelfSignedCert(dummyCertName); + var inMemoryCertificate = CreateSelfSignedCert(inMemoryCertName); + AuthContext appOnlyAuthContext = new AuthContext + { + AuthType = AuthenticationType.AppOnly, + TokenCredentialType = TokenCredentialType.ClientCertificate, + ClientId = mockAuthRecord.ClientId, + CertificateThumbprint = storedDummyCertificate.Thumbprint, + Certificate = inMemoryCertificate, + ContextScope = ContextScope.Process, + TenantId = mockAuthRecord.TenantId + }; + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + //CleanUp + DeleteSelfSignedCertByThumbprint(appOnlyAuthContext.CertificateThumbprint); + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldThrowIfNonExistentCertNameIsProvidedAsync() + { + // Arrange + var dummyCertName = "CN=NonExistingCert"; + AuthContext appOnlyAuthContext = new AuthContext + { + AuthType = AuthenticationType.AppOnly, + TokenCredentialType = TokenCredentialType.ClientCertificate, + ClientId = mockAuthRecord.ClientId, + CertificateSubjectName = dummyCertName, + ContextScope = ContextScope.Process, + TenantId = mockAuthRecord.TenantId + }; + + // Act + var exception = await Assert.ThrowsAsync(async () => await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default)); + + //Assert + Assert.Equal(string.Format(CultureInfo.InvariantCulture, PowerShell.Authentication.Core.ErrorConstants.Message.CertificateNotFound, "subject name", dummyCertName), exception.Message); + } + + [Fact] + public async Task ShouldThrowIfNullInMemoryCertIsProvidedAsync() + { + // Arrange + AuthContext appOnlyAuthContext = new AuthContext + { + AuthType = AuthenticationType.AppOnly, + TokenCredentialType = TokenCredentialType.ClientCertificate, + ClientId = mockAuthRecord.ClientId, + Certificate = null, + ContextScope = ContextScope.Process, + TenantId = mockAuthRecord.TenantId + }; + + // Act + var exception = await Assert.ThrowsAsync(async () => await AuthenticationHelpers.GetTokenCredentialAsync(appOnlyAuthContext, default)); + + //Assert + Assert.Equal("certificate", exception.ParamName); + } + + /// + /// Create and Store a Self Signed Certificate + /// + /// + private static X509Certificate2 CreateAndStoreSelfSignedCert(string certName, StoreLocation storeLocation = StoreLocation.CurrentUser) + { + var cert = CreateSelfSignedCert(certName); + using (var store = new X509Store(StoreName.My, storeLocation)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(cert); + } + + return cert; + } + + /// + /// Create a Self Signed Certificate + /// + /// + /// + + #pragma warning disable IA5352 + private static X509Certificate2 CreateSelfSignedCert(string certName) + { + ECDsa ecdsaKey = ECDsa.Create(); + CertificateRequest certificateRequest = new CertificateRequest(certName, ecdsaKey, HashAlgorithmName.SHA256); + // We have to export cert to dummy cert since `CreateSelfSigned` creates a cert without a private key. + X509Certificate2 cert = certificateRequest.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return new X509Certificate2(cert.Export(X509ContentType.Pfx, "P@55w0rd"), "P@55w0rd", X509KeyStorageFlags.Exportable); + else + return new X509Certificate2(cert.Export(X509ContentType.Pfx, "P@55w0rd"), "P@55w0rd", X509KeyStorageFlags.PersistKeySet); + } + + private static void DeleteSelfSignedCertByName(string certificateName, StoreLocation storeLocation = StoreLocation.CurrentUser) + { + using (X509Store xStore = new X509Store(StoreName.My, storeLocation)) + { + xStore.Open(OpenFlags.ReadWrite); + + X509Certificate2Collection unexpiredCerts = xStore.Certificates + .Find(X509FindType.FindByTimeValid, DateTime.Now, false) + .Find(X509FindType.FindBySubjectDistinguishedName, certificateName, false); + + // Only return current cert. + var xCertificate = unexpiredCerts + .OfType() + .OrderByDescending(c => c.NotBefore) + .FirstOrDefault(); + + xStore.Remove(xCertificate); + } + } + private static void DeleteSelfSignedCertByThumbprint(string certificateThumbPrint, StoreLocation storeLocation = StoreLocation.CurrentUser) + { + using (X509Store xStore = new X509Store(StoreName.My, storeLocation)) + { + xStore.Open(OpenFlags.ReadWrite); + + X509Certificate2Collection unexpiredCerts = xStore.Certificates + .Find(X509FindType.FindByTimeValid, DateTime.Now, false) + .Find(X509FindType.FindByThumbprint, certificateThumbPrint, false); + + // Only return current cert. + var xCertificate = unexpiredCerts + .OfType() + .OrderByDescending(c => c.NotBefore) + .FirstOrDefault(); + + xStore.Remove(xCertificate); + } + } + + [Fact] + public async Task ShouldUseInteractiveBrowserWhenWamIsDisabledWithCustomClientIdAsync() + { + // Arrange + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = true; + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + ClientId = Guid.NewGuid().ToString(), // Custom ClientId + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.InteractiveBrowser + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // Verify that we're NOT using WAM (InteractiveBrowserCredentialBrokerOptions) by checking credential type + // On Windows, if WAM was enabled, it would use InteractiveBrowserCredentialBrokerOptions + // Since we disabled it with custom ClientId, it should use regular InteractiveBrowserCredential + + // reset static instance. + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldStillUseWamWhenDisabledWithDefaultClientIdAsync() + { + // Arrange + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = true; + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + // ClientId not set, will use default from constructor + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.InteractiveBrowser + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // On Windows with default ClientId, WAM should still be enabled even when DisableWAMForMSGraph is true + // This is verified by the credential being created with InteractiveBrowserCredentialBrokerOptions internally + + // reset static instance. + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseWamWhenNotDisabledWithDefaultClientIdAsync() + { + // Arrange + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = false; + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + // ClientId not set, will use default from constructor + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.InteractiveBrowser + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // reset static instance. + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseWamWhenNotDisabledWithCustomClientIdAsync() + { + // Arrange + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = false; + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + ClientId = Guid.NewGuid().ToString(), // Custom ClientId + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.InteractiveBrowser + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // reset static instance. + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseWamWhenNullWithDefaultClientIdAsync() + { + // Arrange + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + // ClientId not set, will use default from constructor + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.InteractiveBrowser + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // When DisableWAMForMSGraph is null (default), WAM should be enabled + // reset static instance. + GraphSession.Reset(); + } + + [Fact] + public async Task ShouldUseWamWhenNullWithCustomClientIdAsync() + { + // Arrange + GraphSession.Instance.GraphOption.DisableWAMForMSGraph = null; + AuthContext delegatedAuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + ClientId = Guid.NewGuid().ToString(), // Custom ClientId + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.InteractiveBrowser + }; + + // Act + TokenCredential tokenCredential = await AuthenticationHelpers.GetTokenCredentialAsync(delegatedAuthContext, default); + + // Assert + _ = Assert.IsType(tokenCredential); + + // When DisableWAMForMSGraph is null (default), WAM should be enabled regardless of ClientId + // reset static instance. + GraphSession.Reset(); + } + + + [Fact] + public async Task SignInAsync_ShouldCallGetTokenAsync_WithCaeEnabledAsync() + { + // Arrange: a capturing credential that records the TokenRequestContext it receives. + TokenRequestContext capturedContext = default; + var capturingCredential = new CapturingTokenCredential( + ctx => capturedContext = ctx, + MockConstants.DummyAccessToken); + + AuthContext authContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.DeviceCode + }; + + // Act — call SignInAsync with the capturing credential so we can + // observe the TokenRequestContext passed to GetTokenAsync. + IAuthContext result = await AuthenticationHelpers.SignInAsync( + authContext, CancellationToken.None, capturingCredential); + + // Assert: GetTokenAsync must have been called and provided a context. + Assert.NotNull(capturedContext); + Assert.NotNull(capturedContext.Scopes); + + // GetTokenAsync must receive isCaeEnabled: true so that MSAL caches + // a CAE-capable token that can be served silently by + // AzureIdentityAccessTokenProvider during subsequent API calls. + Assert.True(capturedContext.IsCaeEnabled, + "SignInAsync must pass isCaeEnabled: true to GetTokenAsync so the " + + "cached token matches the CAE-enabled context used during API calls."); + + // Verify the scopes forwarded to the credential match the original request. + Assert.Equal(new[] { "User.Read" }, capturedContext.Scopes); + + // Verify that the returned token was decoded and the auth context populated. + Assert.Equal("mockAppId", result.ClientId); + Assert.Equal("mockTid", result.TenantId); + Assert.Equal("upn@contoso.com", result.Account); + Assert.Equal("mockName", result.AppName); + + // reset static instance. + GraphSession.Reset(); + } + + [Fact] + public async Task SignInAndProviderShouldUseSameCaeSettingAsync() + { + // Arrange: two capturing credentials — one for the sign-in path, one for + // the provider path — so we can verify they both receive isCaeEnabled: true. + TokenRequestContext signInContext = default; + var signInCredential = new CapturingTokenCredential( + ctx => signInContext = ctx, + MockConstants.DummyAccessToken); + + TokenRequestContext providerContext = default; + var providerCredential = new CapturingTokenCredential( + ctx => providerContext = ctx, + MockConstants.DummyAccessToken); + + AuthContext authContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + Scopes = new[] { "User.Read" }, + ContextScope = ContextScope.Process, + TokenCredentialType = TokenCredentialType.DeviceCode + }; + + // Act — exercise the production SignInAsync path. + await AuthenticationHelpers.SignInAsync( + authContext, CancellationToken.None, signInCredential); + + // Build the provider the same way GetAuthenticationProviderAsync does, + // but with the capturing credential so we can observe the context. + var authProvider = new AzureIdentityAccessTokenProvider( + credential: providerCredential, + observabilityOptions: null, + isCaeEnabled: true, + scopes: authContext.Scopes); + + _ = await authProvider.GetAuthorizationTokenAsync( + new Uri("https://graph.microsoft.com/v1.0/me")); + + // the cached token silently during API calls. + Assert.NotNull(signInContext); + Assert.True(signInContext.IsCaeEnabled, + "SignInAsync must pass isCaeEnabled: true to GetTokenAsync."); + Assert.NotNull(providerContext); + Assert.True(providerContext.IsCaeEnabled, + "AzureIdentityAccessTokenProvider must forward isCaeEnabled: true."); + + // reset static instance. + GraphSession.Reset(); + } + + /// + /// Minimal that captures the + /// it receives and returns a fixed dummy token. + /// + private sealed class CapturingTokenCredential : TokenCredential + { + private readonly Action _onGetToken; + private readonly string _token; + + public CapturingTokenCredential(Action onGetToken, string token) + { + _onGetToken = onGetToken; + _token = token; + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + _onGetToken(requestContext); + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + _onGetToken(requestContext); + return new ValueTask(new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1))); + } + } + + public void Dispose() => mockAuthRecord.DeleteCache(); + } +}