diff --git a/src/client/Microsoft.Identity.Client/AuthenticationResult.cs b/src/client/Microsoft.Identity.Client/AuthenticationResult.cs index 34358b65bd..779de9abd9 100644 --- a/src/client/Microsoft.Identity.Client/AuthenticationResult.cs +++ b/src/client/Microsoft.Identity.Client/AuthenticationResult.cs @@ -218,7 +218,9 @@ private AuthenticationResult( } UniqueId = msalIdTokenCacheItem?.IdToken?.GetUniqueId(); - TenantId = msalIdTokenCacheItem?.IdToken?.TenantId; + // For client credentials flow, ID token is not available, so fall back to access token's tenant + // See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/5743 + TenantId = msalIdTokenCacheItem?.IdToken?.TenantId ?? msalAccessTokenCacheItem?.TenantId; IdToken = msalIdTokenCacheItem?.Secret; SpaAuthCode = spaAuthCode; _authenticationScheme = authenticationScheme; diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs index a7ad4a511f..56d42c363f 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs @@ -221,6 +221,130 @@ public async Task WithOnBeforeTokenRequest_TestAsync(Cloud cloud, TargetFramewor Assert.AreEqual(TokenSource.Cache, authResult.AuthenticationResultMetadata.TokenSource); } + [TestMethod] + [DataRow(TargetFrameworks.NetCore)] + [Description("Issue #5743: Validates that AuthenticationResult.TenantId is not null for client credentials")] + public async Task ClientCredentials_TenantId_ShouldNotBeNull_Issue5743_Async(TargetFrameworks runOn) + { + runOn.AssertFramework(); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + string[] scopes = new[] { "https://vault.azure.net/.default" }; + + var confidentialApp = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(appConfig.Authority, true) + .WithCertificate(cert) + .WithTestLogging() + .Build(); + + // First call - from identity provider + var result = await confidentialApp + .AcquireTokenForClient(scopes) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.TenantId, + "TenantId should not be null in AuthenticationResult for client credentials flow!"); + Assert.AreEqual(appConfig.TenantId, result.TenantId, + "TenantId should match the configured tenant"); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + // Second call - from cache + result = await confidentialApp + .AcquireTokenForClient(scopes) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.TenantId, + "TenantId should not be null even when token comes from cache!"); + Assert.AreEqual(appConfig.TenantId, result.TenantId, + "Cached token should preserve TenantId"); + Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource); + } + + [TestMethod] + [DataRow(TargetFrameworks.NetCore)] + [Description("Issue #5743: Validates TenantId when using WithTenantId override with organizations authority")] + public async Task ClientCredentials_WithTenantIdOverride_ShouldPopulateTenantId_Issue5743_Async(TargetFrameworks runOn) + { + runOn.AssertFramework(); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + string[] scopes = new[] { "https://vault.azure.net/.default" }; + + // Build app with "organizations" authority + var confidentialApp = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority("https://login.microsoftonline.com/organizations", true) + .WithCertificate(cert) + .WithTestLogging() + .Build(); + + // Request token with specific tenant override + var result = await confidentialApp + .AcquireTokenForClient(scopes) + .WithTenantId(appConfig.TenantId) + .ExecuteAsync() + .ConfigureAwait(false); + + // ❌ BUG #5743: This assertion will FAIL + Assert.IsNotNull(result.TenantId, + "❌ BUG #5743: TenantId must be populated when using WithTenantId override!"); + Assert.AreEqual(appConfig.TenantId, result.TenantId, + "TenantId should match the overridden tenant, not 'organizations'"); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + // Second call from cache + result = await confidentialApp + .AcquireTokenForClient(scopes) + .WithTenantId(appConfig.TenantId) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.TenantId, + "❌ BUG #5743: Cached token should also have TenantId populated!"); + Assert.AreEqual(appConfig.TenantId, result.TenantId); + Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource); + } + + [TestMethod] + [DataRow(TargetFrameworks.NetCore)] + [Description("Issue #5743: Rapid tenant switching should maintain correct TenantId")] + public async Task ClientCredentials_RapidTenantSwitching_Issue5743_Async(TargetFrameworks runOn) + { + runOn.AssertFramework(); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + string[] scopes = new[] { "https://vault.azure.net/.default" }; + + var confidentialApp = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority("https://login.microsoftonline.com/organizations", true) + .WithCertificate(cert) + .WithTestLogging() + .Build(); + + // Rapidly switch between same tenant multiple times with force refresh + for (int i = 0; i < 10; i++) + { + var result = await confidentialApp + .AcquireTokenForClient(scopes) + .WithTenantId(appConfig.TenantId) + .WithForceRefresh(true) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.TenantId, + $"❌ BUG #5743: Iteration {i}: TenantId should not be null!"); + Assert.AreEqual(appConfig.TenantId, result.TenantId, + $"Iteration {i}: TenantId should match requested tenant"); + } + } + [RunOn(TargetFrameworks.NetCore)] public async Task ByRefreshTokenTestAsync() { @@ -357,6 +481,12 @@ private async Task RunClientCredsAsync(Cloud cloud, CredentialType credentialTyp MsalAssert.AssertAuthResult(authResult); appCacheRecorder.AssertAccessCounts(1, 1); Assert.AreEqual(TokenSource.IdentityProvider, authResult.AuthenticationResultMetadata.TokenSource); + + // Issue #5743: Validate TenantId is populated + Assert.IsNotNull(authResult.TenantId, + "❌ BUG #5743: TenantId should not be null for client credentials!"); + Assert.AreEqual(tenantId, authResult.TenantId, + "TenantId should match the tenant from authority"); Assert.IsTrue(appCacheRecorder.LastAfterAccessNotificationArgs.IsApplicationCache); Assert.IsTrue(appCacheRecorder.LastAfterAccessNotificationArgs.HasTokens); Assert.AreEqual(correlationId, appCacheRecorder.LastAfterAccessNotificationArgs.CorrelationId); @@ -385,6 +515,12 @@ private async Task RunClientCredsAsync(Cloud cloud, CredentialType credentialTyp appCacheRecorder.AssertAccessCounts(2, 1); Assert.AreEqual(TokenSource.Cache, authResult.AuthenticationResultMetadata.TokenSource); + + // Issue #5743: Validate TenantId is preserved in cached result + Assert.IsNotNull(authResult.TenantId, + "❌ BUG #5743: TenantId should not be null even from cache!"); + Assert.AreEqual(tenantId, authResult.TenantId, + "Cached token should preserve TenantId"); Assert.IsTrue(appCacheRecorder.LastAfterAccessNotificationArgs.IsApplicationCache); Assert.IsTrue(appCacheRecorder.LastAfterAccessNotificationArgs.HasTokens); Assert.AreNotEqual(correlationId, appCacheRecorder.LastAfterAccessNotificationArgs.CorrelationId);