diff --git a/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs b/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs index 9f138860d4..8ddc784c6e 100644 --- a/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs @@ -14,6 +14,7 @@ using Microsoft.Identity.Client.Utils; using Microsoft.Identity.Test.Common.Core.Helpers; using Microsoft.Identity.Test.Common.Core.Mocks; +using Microsoft.Identity.Test.Unit.TestUtils; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.Identity.Test.Unit.CoreTests.InstanceTests @@ -21,6 +22,13 @@ namespace Microsoft.Identity.Test.Unit.CoreTests.InstanceTests [TestClass] public class GenericAuthorityTests : TestBase { + [ClassInitialize] + public static async Task ClassInit(TestContext context) + { + // Use the new method to initialize with just the oidc scenario + await ConfigService.InitializeAsync("oidc", true).ConfigureAwait(false); + } + [DataTestMethod] [DataRow(true)] [DataRow(false)] @@ -334,42 +342,37 @@ public async Task BadOidcResponse_ThrowsException_Async(string badOidcResponseTy } [TestMethod] + [Description("Tests that OIDC issuer validation properly fails when issuers don't match")] public async Task OidcIssuerValidation_ThrowsForNonMatchingIssuer_Async() { - using (var httpManager = new MockHttpManager()) - { - string wrongIssuer = "https://wrong.issuer.com"; - - IConfidentialClientApplication app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithHttpManager(httpManager) - .WithOidcAuthority(TestConstants.GenericAuthority) - .WithClientSecret(TestConstants.ClientSecret) - .Build(); - - // Create OIDC document with non-matching issuer - string validOidcDocumentWithWrongIssuer = TestConstants.GenericOidcResponse.Replace( - $"\"issuer\":\"{TestConstants.GenericAuthority}\"", - $"\"issuer\":\"{wrongIssuer}\""); - - // Mock OIDC endpoint response - httpManager.AddMockHandler(new MockHttpMessageHandler - { - ExpectedMethod = HttpMethod.Get, - ExpectedUrl = $"{TestConstants.GenericAuthority}/{Constants.WellKnownOpenIdConfigurationPath}", - ResponseMessage = MockHelpers.CreateSuccessResponseMessage(validOidcDocumentWithWrongIssuer) - }); - - var ex = await AssertException.TaskThrowsAsync(() => - app.AcquireTokenForClient(new[] { "api" }).ExecuteAsync() - ).ConfigureAwait(false); - - string expectedErrorMessage = string.Format(MsalErrorMessage.IssuerValidationFailed, app.Authority, wrongIssuer); + // Get the test scenario configuration + var scenario = ConfigService.GetScenario("nonMatchingIssuer"); + + // Get the wrong issuer URL and test service authority from the config + string wrongIssuer = ConfigService.GetIssuer(scenario.AuthorityKey); + string testServiceAuthority = scenario.CreateOidcAuthorityUri(); + + // Get the expected error from the configuration + string expectedErrorCode = scenario.GetValue("expectedError"); + + IConfidentialClientApplication app = ConfidentialClientApplicationBuilder + .Create(ConfigService.GetClientId()) + .WithOidcAuthority(testServiceAuthority) + .WithClientSecret(ConfigService.GetClientSecret()) + .WithHttpClientFactory(ConfigService.HttpClientFactory) + .Build(); - Assert.AreEqual(MsalError.AuthorityValidationFailed, ex.ErrorCode); - Assert.AreEqual(expectedErrorMessage, ex.Message, - "Error message should match the expected error message."); - } + var ex = await AssertException.TaskThrowsAsync(() => + app.AcquireTokenForClient(new[] { "api" }).ExecuteAsync() + ).ConfigureAwait(false); + + string expectedErrorMessage = string.Format(MsalErrorMessage.IssuerValidationFailed, app.Authority, wrongIssuer); + + // Assert using the error code from the configuration + Assert.AreEqual(expectedErrorCode, ex.ErrorCode, + $"Error code should match the expected error code '{expectedErrorCode}'"); + Assert.AreEqual(expectedErrorMessage, ex.Message, + "Error message should match the expected error message."); } private static MockHttpMessageHandler CreateTokenResponseHttpHandler( diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TokenRevocationTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TokenRevocationTests.cs new file mode 100644 index 0000000000..b73ca71b97 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/TokenRevocationTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.Identity.Test.Unit.TestUtils; +using Microsoft.VisualStudio.TestTools.UnitTesting; +#pragma warning disable CS0618 // Type or member is obsolete + +namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests +{ + /// + /// Tests for token revocation scenarios with Managed Identity + /// + [TestClass] + public class TokenRevocationTests : TestBase + { + [ClassInitialize] + public static async Task ClassInit(TestContext context) + { + await ConfigService.InitializeAsync("token-revocation", true).ConfigureAwait(false); + } + + [TestMethod] + [Description("Tests that a revoked token is properly replaced when a claims challenge is received")] + public async Task ServiceFabric_WithTokenRevocation_RetrievesNewToken_Async() + { + // Get the test scenario configuration using the TestScenario API + var scenario = ConfigService.GetScenario("serviceFabricRevocation"); + + // Get the resource and endpoints from the scenario helper + string resource = scenario.Resource; + string serviceUri = scenario.CreateIdentityProviderUri_SuccessfulToken(); + string revocationEndpoint = scenario.CreateRevocationEndpointUri(); + + // Build managed identity application with client capabilities for token revocation support + var miBuilder = ManagedIdentityApplicationBuilder.Create(ManagedIdentityId.SystemAssigned) + .WithClientCapabilities(["cp1"]) // Client capability needed for token revocation + .WithHttpClientFactory(ConfigService.HttpClientFactory); + + // Set the Service Fabric environment variable to point to our test service + using (new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("IDENTITY_ENDPOINT", serviceUri); + Environment.SetEnvironmentVariable("IDENTITY_HEADER", "service-fabric-test-header"); + + IManagedIdentityApplication mi = miBuilder.Build(); + + // PHASE 1: Initial token acquisition + // Get the initial token - should succeed and be cached + var result1 = await mi.AcquireTokenForManagedIdentity(resource) + .ExecuteAsync() + .ConfigureAwait(false); + + // ASSERT - PHASE 1 + Assert.IsNotNull(result1); + Assert.IsNotNull(result1.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + string initialToken = result1.AccessToken; + + // Verify we can get the same token from cache + var resultFromCache = await mi.AcquireTokenForManagedIdentity(resource) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, resultFromCache.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(initialToken, resultFromCache.AccessToken); + + // PHASE 2: Simulate token revocation scenario + // This simulates what would happen when a token is revoked and a resource rejects it + string claimsChallenge = await SimulateTokenRejectionAndGetClaimsChallengeAsync( + initialToken, + revocationEndpoint, + ConfigService.HttpClientFactory.GetHttpClient()).ConfigureAwait(false); + + // PHASE 3: Get a new token using the claims challenge + // Use the claims challenge we got from the simulated resource rejection + var result2 = await mi.AcquireTokenForManagedIdentity(resource) + .WithClaims(claimsChallenge) + .ExecuteAsync() + .ConfigureAwait(false); + + // ASSERT - PHASE 3 + Assert.IsNotNull(result2); + Assert.IsNotNull(result2.AccessToken); + + // The token should come from the identity provider, not the cache + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); + + // The new token should be different from the original one + Assert.AreNotEqual(initialToken, result2.AccessToken, "Token should be different after revocation"); + + // Verify the revoked token is no longer returned from cache, and new token is used instead + var resultAfterRevocation = await mi.AcquireTokenForManagedIdentity(resource) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, resultAfterRevocation.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(result2.AccessToken, resultAfterRevocation.AccessToken); + Assert.AreNotEqual(initialToken, resultAfterRevocation.AccessToken); + } + } + + /// + /// Simulates calling a resource with a token that has been revoked, and getting back a claims challenge + /// + /// The token to use in the request + /// The endpoint that simulates token revocation + /// HttpClient to use for the request + /// The claims challenge string returned by the service + private static async Task SimulateTokenRejectionAndGetClaimsChallengeAsync( + string token, + string revocationEndpoint, + HttpClient httpClient) + { + // Create a request to the revocation endpoint with the token in the Authorization header + var request = new HttpRequestMessage(HttpMethod.Get, revocationEndpoint); + request.Headers.Add("Authorization", $"Bearer {token}"); + + // Send the request to simulate accessing a resource with a revoked token + var response = await httpClient.SendAsync(request).ConfigureAwait(false); + + // We expect a 401 Unauthorized response with a WWW-Authenticate header containing the claims challenge + if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) + { + throw new InvalidOperationException($"Expected 401 Unauthorized from revocation simulation endpoint, but got {response.StatusCode}"); + } + + // Parse the WWW-Authenticate header to get the claims challenge + WwwAuthenticateParameters authParams = WwwAuthenticateParameters.CreateFromWwwAuthenticateHeaderValue( + response.Headers.WwwAuthenticate.ToString()); + + return authParams.Claims; + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/TestUtils/ConfigService.cs b/tests/Microsoft.Identity.Test.Unit/TestUtils/ConfigService.cs new file mode 100644 index 0000000000..58ecb76abc --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/TestUtils/ConfigService.cs @@ -0,0 +1,334 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Identity.Client; + +namespace Microsoft.Identity.Test.Unit.TestUtils +{ + /// + /// Service to retrieve and access test configuration from an external test service. + /// This allows tests to be configured externally and supports simulation of various + /// authentication scenarios. + /// + public static class ConfigService + { + private static JsonDocument _config; + + /// + /// The production service endpoint URL (for future use with real msidlab.com service) + /// + private const string ProductionServiceEndpoint = "https://msidlab.com"; + + /// + /// The localhost service endpoint URL for development and testing + /// + private const string LocalhostServiceEndpoint = "https://localhost:5001"; + + /// + /// Flag to determine if the service should use localhost instead of the production endpoint + /// + private static bool _useLocalhost = false; + + /// + /// The base endpoint URL for the service + /// + public static string ServiceEndpoint => _useLocalhost ? LocalhostServiceEndpoint : ProductionServiceEndpoint; + + /// + /// Gets the IMsalHttpClientFactory used by ConfigService, which ignores SSL certificate validation + /// when using localhost + /// + public static IMsalHttpClientFactory HttpClientFactory { get; private set; } + + /// + /// Initialize the configuration service and fetch configuration for the specified scenario + /// + /// Name of the scenario to fetch configuration for, or "all" for everything + /// Whether to use localhost instead of production endpoint + /// Task that completes when configuration is loaded + public static async Task InitializeAsync(string scenario = "all", bool useLocalhost = false) + { + _useLocalhost = useLocalhost; + + // Create an HttpClient that ignores SSL certificate validation errors when using localhost + if (useLocalhost) + { + HttpClientFactory = new InsecureHttpClientFactory(); + } + else + { + // Will be expanded to use the actual msidlab.com endpoint in the future, + // for now only localhost is possible. + } + + try + { + string configUrl = $"{ServiceEndpoint}/api/getConfig/config?scenario={scenario}"; + + var response = await HttpClientFactory.GetHttpClient().GetStringAsync(configUrl).ConfigureAwait(false); + _config = JsonDocument.Parse(response); + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException( + $"Failed to connect to the test service. Make sure the service is running at {ServiceEndpoint}.", + ex); + } + catch (JsonException ex) + { + throw new InvalidOperationException("Invalid JSON response from the test service.", ex); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to load configuration '{scenario}'. Error: {ex.Message}", + ex); + } + } + + /// + /// Navigate to a section in the configuration using a path with dot notation + /// + /// Path to the section, e.g. "authorities.generic" + /// JsonElement for the section + private static JsonElement NavigateToSection(string path) + { + var element = _config.RootElement; + var parts = path.Split('.'); + + foreach (var part in parts) + { + if (!element.TryGetProperty(part, out element)) + { + throw new InvalidOperationException( + $"Configuration section '{part}' not found in path '{path}'."); + } + } + + return element; + } + + /// + /// Get a string value from the configuration using a path with dot notation + /// + /// Path to the value, e.g. "authorities.generic.authority" + /// The string value at the path + /// If the path is null or empty + /// If the value is not a string or the path doesn't exist + public static string GetString(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Path cannot be null or empty.", nameof(path)); + } + + var element = NavigateToSection(path); + if (element.ValueKind == JsonValueKind.String) + { + return element.GetString(); + } + + throw new InvalidOperationException( + $"Configuration value at '{path}' is not a string but is {element.ValueKind}."); + } + + /// + /// Get the authority URL for a given authority key + /// + /// Key of the authority, e.g. "generic", "entra", "ciam" + /// The authority URL + public static string GetAuthority(string authorityKey) + { + if (string.IsNullOrWhiteSpace(authorityKey)) + { + throw new ArgumentException("Authority key cannot be null or empty.", nameof(authorityKey)); + } + + return GetString($"authorities.{authorityKey}.authority"); + } + + /// + /// Get the issuer URL for a given authority key + /// + /// Key of the authority, e.g. "generic", "entra", "ciam" + /// The issuer URL + public static string GetIssuer(string authorityKey) + { + if (string.IsNullOrWhiteSpace(authorityKey)) + { + throw new ArgumentException("Authority key cannot be null or empty.", nameof(authorityKey)); + } + + return GetString($"authorities.{authorityKey}.issuer"); + } + + /// + /// Get the client ID from the configuration + /// + /// The client ID + public static string GetClientId() => GetString("clientCredentials.client_id"); + + /// + /// Get the client secret from the configuration + /// + /// The client secret + public static string GetClientSecret() => GetString("clientCredentials.client_secret"); + + /// + /// Get the URI to the test service with the specified path appended + /// + /// Path to append + /// Full URI to the test service + public static string GetServiceUri(string path) + { + if (string.IsNullOrEmpty(path)) + { + return ServiceEndpoint; + } + + // Ensure path starts with a forward slash + string normalizedPath = path.StartsWith("/") ? path : $"/{path}"; + + return $"{ServiceEndpoint}{normalizedPath}"; + } + + /// + /// Get the response type URI from the configuration + /// + /// Key of the response type, e.g. "oidc_response_successful" + /// The response type URI + public static string GetResponseTypeUri(string responseTypeKey) + { + if (string.IsNullOrWhiteSpace(responseTypeKey)) + { + throw new ArgumentException("Response type key cannot be null or empty.", nameof(responseTypeKey)); + } + + return GetString($"responseTypes.{responseTypeKey}"); + } + + /// + /// Get a test scenario directly by its name + /// + /// The name of the scenario (e.g. "serviceFabricRevocation" or "nonMatchingIssuer") + /// A test scenario configuration object + public static TestScenario GetScenario(string scenarioName) + { + if (string.IsNullOrWhiteSpace(scenarioName)) + { + throw new ArgumentException("Scenario name cannot be null or empty.", nameof(scenarioName)); + } + + string basePath = $"testScenarios.{scenarioName}"; + return new TestScenario(basePath); + } + + /// + /// Class representing a test scenario with its configuration values and helper methods + /// + public class TestScenario + { + private readonly string _basePath; + + /// + /// Creates a new test scenario with the given base path + /// + /// The base path to the scenario in the configuration + public TestScenario(string basePath) + { + _basePath = basePath ?? throw new ArgumentNullException(nameof(basePath)); + } + + /// + /// Get a value from this test scenario + /// + /// The key of the value to retrieve + /// The value as a string + public string GetValue(string key) => GetString($"{_basePath}.{key}"); + + /// + /// The authority key for this scenario, used to look up authority and issuer URLs + /// + public string AuthorityKey => GetValue("authorityKey"); + + /// + /// The response type key for this scenario, used to determine which endpoint to call + /// + public string ResponseType => GetValue("responseType"); + + /// + /// The resource value for token acquisition scenarios + /// + public string Resource => GetValue("resource"); + + /// + /// Create a service URI for this scenario's response type + /// + /// Optional additional path to append + /// The full service URI + public string CreateServiceUri(string additionalPath = null) + { + string path = GetResponseTypeUri(ResponseType); + + if (string.IsNullOrEmpty(additionalPath)) + { + return GetServiceUri(path); + } + + return GetServiceUri($"{path}/{additionalPath}"); + } + + /// + /// Create an authority URI for OIDC scenarios, with an encoded issuer URL that will be + /// passed to the service to help configure the response. + /// + /// The full OIDC authority URI + public string CreateOidcAuthorityUri() + { + var issuerUrl = GetIssuer(AuthorityKey); + var encodedIssuer = System.Net.WebUtility.UrlEncode(issuerUrl); + return CreateServiceUri(encodedIssuer); + } + + /// + /// Create an identity provider URI for token revocation scenarios + /// + /// The identity provider URI + public string CreateIdentityProviderUri_SuccessfulToken() + { + string tokenResponsePath = GetResponseTypeUri("token_successful"); + return GetServiceUri($"{tokenResponsePath}"); + } + + /// + /// Create a revocation endpoint URI for token revocation scenarios + /// + /// The revocation endpoint URI + public string CreateRevocationEndpointUri() + { + string revocationPath = GetValue("revocationEndpoint"); + return GetServiceUri(revocationPath); + } + } + + /// + /// HttpClientFactory that ignores SSL certificate validation for test purposes + /// + public class InsecureHttpClientFactory : IMsalHttpClientFactory + { + /// + /// Get an HttpClient with SSL certificate validation disabled + /// + /// HttpClient instance + public HttpClient GetHttpClient() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true + }; + + return new HttpClient(handler); + } + } + } +}