diff --git a/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs b/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs index eb6342a38..61140e18a 100644 --- a/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs +++ b/tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs @@ -9,7 +9,7 @@ namespace Microsoft.Identity.Web.Test.Common { - + public static class TestConstants { public const string ProductionPrefNetworkEnvironment = "login.microsoftonline.com"; @@ -116,8 +116,13 @@ public static class TestConstants public const string LabClientId = "f62c5ae3-bf3a-4af5-afa8-a68b800396e9"; public const string MSIDLabLabKeyVaultName = "https://msidlabs.vault.azure.net"; public const string AzureADIdentityDivisionTestAgentSecret = "MSIDLAB4-IDLABS-APP-AzureADMyOrg-CC"; + public const string DstsTestClientSecret = "MISE-dSTS-CustomAppConfig"; // dSTS test app client secret in ID4sKeyVault public const string BuildAutomationKeyVaultName = "https://buildautomation.vault.azure.net/"; + // ID4s Key Vault (MsalTeam) - used for test configurations + public const string ID4sKeyVaultName = "id4skeyvault"; + public const string ID4sKeyVaultUri = "https://id4skeyvault.vault.azure.net/"; + // This value is only for testing purposes. It is for a certificate that is not used for anything other than running tests public const string CertificateX5c = @"MIIDHzCCAgegAwIBAgIQM6NFYNBJ9rdOiK+C91ZzFDANBgkqhkiG9w0BAQsFADAgMR4wHAYDVQQDExVBQ1MyQ2xpZW50Q2VydGlmaWNhdGUwHhcNMTIwNTIyMj IxMTIyWhcNMzAwNTIyMDcwMDAwWjAgMR4wHAYDVQQDExVBQ1MyQ2xpZW50Q2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCh7HjK @@ -217,7 +222,7 @@ public static class TestConstants ""preferred_network"":""login.microsoftonline.com"", ""preferred_cache"":""login.windows.net"", ""aliases"":[ - ""login.microsoftonline.com"", + ""login.microsoftonline.com"", ""login.windows.net"", ""login.microsoft.com"", ""sts.windows.net""]}, diff --git a/tests/Microsoft.Identity.Web.Test.Integration/AcquireTokenForAppDstsIntegrationTests.cs b/tests/Microsoft.Identity.Web.Test.Integration/AcquireTokenForAppDstsIntegrationTests.cs new file mode 100644 index 000000000..41abbda4b --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test.Integration/AcquireTokenForAppDstsIntegrationTests.cs @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Lab.Api; +using Microsoft.Identity.Web.Test.Common; +using Microsoft.Identity.Web.Test.Common.Mocks; +using Microsoft.Identity.Web.Test.Common.TestHelpers; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; +using System.Text.Json; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Identity.Web.Test.Integration +{ +#if !FROM_GITHUB_ACTION + /// + /// These tests verify that Microsoft.Identity.Web can successfully acquire tokens + /// + public class AcquireTokenForAppDstsIntegrationTests + { + private TokenAcquisition _tokenAcquisition = default!; + private ServiceProvider? _provider; + private MsalTestTokenCacheProvider _msalTestTokenCacheProvider = default!; + private IOptionsMonitor _microsoftIdentityOptionsMonitor = default!; + private IOptionsMonitor _applicationOptionsMonitor = default!; + private ICredentialsLoader _credentialsLoader = default!; + + private readonly string _dstsAuthority; + private readonly string _dstsClientId; + private readonly CredentialDescription _dstsCredential; + private readonly string _dstsTenantId; + private readonly string _dstsScope; + private readonly ITestOutputHelper _output; + + private ServiceProvider Provider { get => _provider!; } + + public AcquireTokenForAppDstsIntegrationTests(ITestOutputHelper output) + { + _output = output; + + try + { + // Load dSTS configuration from Key Vault (ID4sKeyVault) + KeyVaultSecretsProvider kvProvider = new KeyVaultSecretsProvider(TestConstants.ID4sKeyVaultUri); + + var dstsConfigSecret = kvProvider.GetSecretByName(TestConstants.DstsTestClientSecret); + + if (dstsConfigSecret == null || string.IsNullOrEmpty(dstsConfigSecret.Value)) + { + throw new InvalidOperationException( + $"dSTS configuration not found in Key Vault. " + + $"Secret name: '{TestConstants.DstsTestClientSecret}' in vault: '{TestConstants.ID4sKeyVaultName}'"); + } + + // Parse JSON using Lab API standard format + var jsonDoc = JsonDocument.Parse(dstsConfigSecret.Value); + var appElement = jsonDoc.RootElement.GetProperty("app"); + + // Extract and validate configuration values + string? authority = appElement.GetProperty("authority").GetString(); + string? clientId = appElement.GetProperty("appid").GetString(); + string? tenantId = appElement.GetProperty("tenantid").GetString(); + string? scope = appElement.GetProperty("defaultscopes").GetString(); + + if (string.IsNullOrEmpty(authority)) + { + throw new InvalidOperationException("Authority is required in dSTS configuration"); + } + + if (string.IsNullOrEmpty(clientId)) + { + throw new InvalidOperationException("ClientId is required in dSTS configuration"); + } + + if (string.IsNullOrEmpty(tenantId)) + { + throw new InvalidOperationException("TenantId is required in dSTS configuration"); + } + + if (string.IsNullOrEmpty(scope)) + { + throw new InvalidOperationException("Scope is required in dSTS configuration"); + } + + _dstsAuthority = authority; + _dstsClientId = clientId; + _dstsTenantId = tenantId; + _dstsScope = scope; + + // Load certificate + _dstsCredential = CertificateDescription.FromKeyVault( + TestConstants.MSIDLabLabKeyVaultName, + "LabAuth"); + + _output.WriteLine("✅ dSTS configuration and certificate loaded successfully from Key Vault"); + } + catch (InvalidOperationException) + { + // Re-throw our own validation exceptions + throw; + } + catch (Exception ex) + { + _output.WriteLine($"❌ Failed to load dSTS configuration from Key Vault: {ex.Message}"); + throw new InvalidOperationException( + $"Failed to initialize dSTS integration tests. " + + $"Please ensure the secret '{TestConstants.DstsTestClientSecret}' exists in Key Vault '{TestConstants.ID4sKeyVaultName}'. " + + $"See README_DSTS_TESTS.md for setup instructions.", + ex); + } + + BuildTheRequiredServices(); + } + + /// + /// Test acquiring access token from dSTS for app-only scenario. + /// This verifies the basic dSTS token acquisition flow. + /// + [Fact] + public async Task GetAccessTokenForApp_FromDsts_ReturnsAccessTokenAsync() + { + // Arrange + InitializeTokenAcquisitionObjects(); + + Assert.Equal(0, _msalTestTokenCacheProvider.Count); + + // Act + string token = await _tokenAcquisition.GetAccessTokenForAppAsync(_dstsScope); + + // Assert + Assert.NotNull(token); + Assert.NotEmpty(token); + _output.WriteLine("✅ Successfully acquired token from dSTS"); + + // Verify token is cached + Assert.Equal(1, _msalTestTokenCacheProvider.Count); + } + + /// + /// Test acquiring AuthenticationResult from dSTS for app-only scenario. + /// This verifies that the full authentication result is returned correctly. + /// + [Fact(Skip = "Requires dSTS configuration in Key Vault. Set 'DstsTestConfig' secret to enable.")] + public async Task GetAuthenticationResultForApp_FromDsts_ReturnsAuthResultAsync() + { + // Arrange + InitializeTokenAcquisitionObjects(); + + Assert.Equal(0, _msalTestTokenCacheProvider.Count); + + // Act + AuthenticationResult authResult = await _tokenAcquisition.GetAuthenticationResultForAppAsync(_dstsScope); + + // Assert + Assert.NotNull(authResult); + Assert.NotNull(authResult.AccessToken); + Assert.NotEmpty(authResult.AccessToken); + Assert.Null(authResult.IdToken); // App-only flow should not return ID token + Assert.Null(authResult.Account); // App-only flow should not return account + _output.WriteLine("✅ Successfully acquired AuthenticationResult from dSTS"); + + // Verify token is cached + Assert.Equal(1, _msalTestTokenCacheProvider.Count); + } + + /// + /// Test token acquisition with PoP (Proof of Possession) from dSTS. + /// This verifies that dSTS supports PoP tokens if configured. + /// + [Fact(Skip = "Requires dSTS configuration in Key Vault. Set 'DstsTestConfig' secret to enable.")] + public async Task GetAuthenticationResultForApp_FromDsts_WithPoP_ReturnsPopTokenAsync() + { + // Arrange + InitializeTokenAcquisitionObjects(); + + Assert.Equal(0, _msalTestTokenCacheProvider.Count); + + TokenAcquisitionOptions tokenAcquisitionOptions = new TokenAcquisitionOptions + { + PoPConfiguration = new Client.AppConfig.PoPAuthenticationConfiguration(new Uri("https://management.core.windows.net")) + }; + + // Act + AuthenticationResult authResult = await _tokenAcquisition.GetAuthenticationResultForAppAsync( + _dstsScope, + tokenAcquisitionOptions: tokenAcquisitionOptions); + + // Assert + Assert.NotNull(authResult); + Assert.NotNull(authResult.AccessToken); + Assert.Contains("PoP", authResult.CreateAuthorizationHeader(), StringComparison.OrdinalIgnoreCase); + _output.WriteLine("✅ Successfully acquired PoP token from dSTS"); + + // Verify token is cached + Assert.Equal(1, _msalTestTokenCacheProvider.Count); + } + + /// + /// Test that token is retrieved from cache on subsequent calls. + /// This verifies the token caching mechanism works with dSTS tokens. + /// + [Fact(Skip = "Requires dSTS configuration in Key Vault. Set 'DstsTestConfig' secret to enable.")] + public async Task GetAccessTokenForApp_FromDsts_MultipleCallsUseCacheAsync() + { + // Arrange + InitializeTokenAcquisitionObjects(); + + Assert.Equal(0, _msalTestTokenCacheProvider.Count); + + // Act - First call (should acquire from dSTS) + string token1 = await _tokenAcquisition.GetAccessTokenForAppAsync(_dstsScope); + Assert.NotNull(token1); + Assert.Equal(1, _msalTestTokenCacheProvider.Count); + + // Act - Second call (should retrieve from cache) + string token2 = await _tokenAcquisition.GetAccessTokenForAppAsync(_dstsScope); + + // Assert + Assert.NotNull(token2); + Assert.Equal(token1, token2); // Should be same token from cache + Assert.Equal(1, _msalTestTokenCacheProvider.Count); // Cache count should not increase + _output.WriteLine("✅ Successfully verified token caching for dSTS tokens"); + } + + /// + /// Test token acquisition with SAML bearer assertion from dSTS. + /// This verifies that dSTS tokens work with the SAML bearer authorization scheme. + /// Note: This test verifies the code path but actual SAML assertion handling + /// is primarily in DownstreamApi when calling downstream APIs. + /// + [Fact(Skip = "Requires dSTS configuration in Key Vault. Set 'DstsTestConfig' secret to enable.")] + public async Task GetAccessTokenForApp_FromDsts_SupportsTokenForDownstreamApiAsync() + { + // Arrange + InitializeTokenAcquisitionObjects(); + + // Act + string token = await _tokenAcquisition.GetAccessTokenForAppAsync(_dstsScope); + + // Assert + Assert.NotNull(token); + Assert.NotEmpty(token); + _output.WriteLine("✅ Token acquired for downstream API call"); + + // The actual SAML bearer header logic is tested in unit tests (DownstreamApiTests) + // This integration test verifies we can acquire tokens that would be used + // with the SAML bearer scheme: "http://schemas.microsoft.com/dsts/saml2-bearer" + } + + /// + /// Test that acquiring token with invalid scope throws appropriate exception. + /// + [Fact(Skip = "Requires dSTS configuration in Key Vault. Set 'DstsTestConfig' secret to enable.")] + public async Task GetAccessTokenForApp_FromDsts_WithInvalidScope_ThrowsExceptionAsync() + { + // Arrange + InitializeTokenAcquisitionObjects(); + + // Act & Assert + await Assert.ThrowsAnyAsync(async () => + await _tokenAcquisition.GetAccessTokenForAppAsync("invalid.scope.that.does.not.exist/.default")); + _output.WriteLine("✅ Successfully verified exception handling for invalid scope"); + } + + private void InitializeTokenAcquisitionObjects() + { + _credentialsLoader = new DefaultCredentialsLoader(); + MergedOptions mergedOptions = Provider.GetRequiredService().Get(OpenIdConnectDefaults.AuthenticationScheme); + + MergedOptions.UpdateMergedOptionsFromMicrosoftIdentityOptions(_microsoftIdentityOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + MergedOptions.UpdateMergedOptionsFromConfidentialClientApplicationOptions(_applicationOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + + _msalTestTokenCacheProvider = new MsalTestTokenCacheProvider( + Provider.GetService()!, + Provider.GetService>()!); + + var tokenAcquisitionAspnetCoreHost = new TokenAcquisitionAspnetCoreHost( + MockHttpContextAccessor.CreateMockHttpContextAccessor(), + Provider.GetService()!, + Provider); + + _tokenAcquisition = new TokenAcquisitionAspNetCore( + _msalTestTokenCacheProvider, + Provider.GetService()!, + Provider.GetService>()!, + tokenAcquisitionAspnetCoreHost, + Provider, + _credentialsLoader); + + tokenAcquisitionAspnetCoreHost.GetOptions(OpenIdConnectDefaults.AuthenticationScheme, out _); + } + + private void BuildTheRequiredServices() + { + _microsoftIdentityOptionsMonitor = new TestOptionsMonitor(new MicrosoftIdentityOptions + { + Authority = _dstsAuthority, + ClientId = _dstsClientId, + CallbackPath = string.Empty, + ClientCredentials = new[] { _dstsCredential }, // Modern credential approach + SendX5C = true, // Required for dSTS certificate authentication + }); + + _applicationOptionsMonitor = new TestOptionsMonitor(new ConfidentialClientApplicationOptions + { + Instance = _dstsAuthority.Substring(0, _dstsAuthority.LastIndexOf('/')), // Extract instance from authority + TenantId = _dstsTenantId, + ClientId = _dstsClientId, + }); + + var services = new ServiceCollection(); + + services.AddTokenAcquisition(); + services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme); + services.AddTransient( + provider => _microsoftIdentityOptionsMonitor); + services.AddTransient( + provider => _applicationOptionsMonitor); + services.Configure(OpenIdConnectDefaults.AuthenticationScheme, options => { }); + services.AddLogging(); + services.AddInMemoryTokenCaches(); + services.AddHttpClient(); + _provider = services.BuildServiceProvider(); + } + } +#endif //FROM_GITHUB_ACTION +}