diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryProvider.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryProvider.java index d7def744..858d84db 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryProvider.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryProvider.java @@ -43,9 +43,14 @@ class AadInstanceDiscoveryProvider { static { TRUSTED_SOVEREIGN_HOSTS_SET.addAll(Arrays.asList( "login.chinacloudapi.cn", + "login.partner.microsoftonline.cn", "login-us.microsoftonline.com", "login.microsoftonline.de", - "login.microsoftonline.us")); + "login.microsoftonline.us", + "login.usgovcloudapi.net", + "login.sovcloud-identity.fr", + "login.sovcloud-identity.de", + "login.sovcloud-identity.sg")); TRUSTED_HOSTS_SET.addAll(Arrays.asList( DEFAULT_TRUSTED_HOST, @@ -142,7 +147,14 @@ static void cacheInstanceDiscoveryResponse(String host, } static void cacheInstanceDiscoveryMetadata(String host) { - cache.putIfAbsent(host, new InstanceDiscoveryMetadataEntry(host, host, Collections.singleton(host))); + InstanceDiscoveryMetadataEntry knownEntry = KnownMetadataProvider.getMetadataEntry(host); + if (knownEntry != null) { + for (String alias : knownEntry.aliases()) { + cache.putIfAbsent(alias, knownEntry); + } + } else { + cache.putIfAbsent(host, new InstanceDiscoveryMetadataEntry(host, host, Collections.singleton(host))); + } } private static boolean shouldUseRegionalEndpoint(MsalRequest msalRequest){ @@ -234,7 +246,7 @@ static AadInstanceDiscoveryResponse sendInstanceDiscoveryRequest(URL authorityUr AadInstanceDiscoveryResponse response = JsonHelper.convertJsonStringToJsonSerializableObject(httpResponse.body(), AadInstanceDiscoveryResponse::fromJson); if (httpResponse.statusCode() != HttpStatus.HTTP_OK) { - if (httpResponse.statusCode() == HttpStatus.HTTP_BAD_REQUEST && response.error().equals("invalid_instance")) { + if (httpResponse.statusCode() == HttpStatus.HTTP_BAD_REQUEST && response.error().equals(AuthenticationErrorCode.INVALID_INSTANCE)) { // instance discovery failed due to an invalid authority, throw an exception. throw MsalServiceExceptionFactory.fromHttpResponse(httpResponse); } @@ -340,7 +352,26 @@ private static void doInstanceDiscoveryAndCache(URL authorityUrl, AadInstanceDiscoveryResponse aadInstanceDiscoveryResponse = null; if (msalRequest.application().authenticationAuthority.authorityType.equals(AuthorityType.AAD)) { - aadInstanceDiscoveryResponse = sendInstanceDiscoveryRequest(authorityUrl, msalRequest, serviceBundle); + try { + aadInstanceDiscoveryResponse = sendInstanceDiscoveryRequest(authorityUrl, msalRequest, serviceBundle); + } catch (MsalServiceException ex) { + // Throw "invalid_instance" errors: this means the authority itself is invalid. + // All other HTTP-level errors (500, 502, 404, etc.) should fall through to the fallback path. + if (ex.errorCode().equals(AuthenticationErrorCode.INVALID_INSTANCE)) { + throw ex; + } + LOG.warn("Instance discovery request failed with a service error. " + + "MSAL will use fallback instance metadata for {}. Error: {}", authorityUrl.getHost(), ex.getMessage()); + cacheInstanceDiscoveryMetadata(authorityUrl.getHost()); + return; + } catch (Exception e) { + // Network failures (timeout, DNS, connection refused) — cache a fallback + // entry so subsequent calls don't retry the failing network call. + LOG.warn("Instance discovery network request failed. " + + "MSAL will use fallback instance metadata for {}. Exception: {}", authorityUrl.getHost(), e.getMessage()); + cacheInstanceDiscoveryMetadata(authorityUrl.getHost()); + return; + } if (validateAuthority) { validate(aadInstanceDiscoveryResponse); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java index 6e7d301c..d5fba3df 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java @@ -160,4 +160,10 @@ public class AuthenticationErrorCode { public static final String CRYPTO_ERROR = "crypto_error"; public static final String INVALID_TIMESTAMP_FORMAT = "invalid_timestamp_format"; + + /** + * Indicates that instance discovery failed because the authority is not a valid instance. + * This is returned by the instance discovery endpoint when the provided authority host is unknown. + */ + public static final String INVALID_INSTANCE = "invalid_instance"; } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/KnownMetadataProvider.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/KnownMetadataProvider.java new file mode 100644 index 00000000..c7d38af7 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/KnownMetadataProvider.java @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.util.*; + +/** + * Provides hardcoded instance discovery metadata for well-known cloud environments. + * This allows correct alias resolution and cache behavior even when the network + * instance discovery endpoint is unreachable. + * + * Mirrors the KnownMetadataProvider in MSAL .NET. + */ +class KnownMetadataProvider { + + private static final Map KNOWN_ENTRIES; + + static { + Map entries = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + addEntry(entries, + "login.microsoftonline.com", "login.windows.net", + "login.microsoftonline.com", "login.windows.net", "login.microsoft.com", "sts.windows.net"); + + addEntry(entries, + "login.partner.microsoftonline.cn", "login.partner.microsoftonline.cn", + "login.partner.microsoftonline.cn", "login.chinacloudapi.cn"); + + addEntry(entries, + "login.microsoftonline.de", "login.microsoftonline.de", + "login.microsoftonline.de"); + + addEntry(entries, + "login.microsoftonline.us", "login.microsoftonline.us", + "login.microsoftonline.us", "login.usgovcloudapi.net"); + + addEntry(entries, + "login-us.microsoftonline.com", "login-us.microsoftonline.com", + "login-us.microsoftonline.com"); + + addEntry(entries, + "login.sovcloud-identity.fr", "login.sovcloud-identity.fr", + "login.sovcloud-identity.fr"); + + addEntry(entries, + "login.sovcloud-identity.de", "login.sovcloud-identity.de", + "login.sovcloud-identity.de"); + + addEntry(entries, + "login.sovcloud-identity.sg", "login.sovcloud-identity.sg", + "login.sovcloud-identity.sg"); + + KNOWN_ENTRIES = Collections.unmodifiableMap(entries); + } + + private static void addEntry(Map entries, + String preferredNetwork, + String preferredCache, + String... aliases) { + Set aliasSet = new LinkedHashSet<>(Arrays.asList(aliases)); + InstanceDiscoveryMetadataEntry entry = new InstanceDiscoveryMetadataEntry(preferredNetwork, preferredCache, aliasSet); + for (String alias : aliases) { + entries.put(alias, entry); + } + } + + /** + * Returns the known metadata entry for the given host, or null if unknown. + */ + static InstanceDiscoveryMetadataEntry getMetadataEntry(String host) { + return KNOWN_ENTRIES.get(host); + } + + /** + * Returns true if the host is a well-known cloud environment. + */ + static boolean isKnownEnvironment(String host) { + return KNOWN_ENTRIES.containsKey(host); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/KnownMetadataProviderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/KnownMetadataProviderTest.java new file mode 100644 index 00000000..ac1293ad --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/KnownMetadataProviderTest.java @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class KnownMetadataProviderTest { + + @Test + void isKnownEnvironment_allKnownHosts_returnsTrue() { + String[] knownHosts = { + "login.microsoftonline.com", "login.windows.net", "login.microsoft.com", "sts.windows.net", + "login.partner.microsoftonline.cn", "login.chinacloudapi.cn", + "login.microsoftonline.de", + "login.microsoftonline.us", "login.usgovcloudapi.net", + "login-us.microsoftonline.com", + "login.sovcloud-identity.fr", "login.sovcloud-identity.de", "login.sovcloud-identity.sg" + }; + for (String host : knownHosts) { + assertTrue(KnownMetadataProvider.isKnownEnvironment(host), + "Expected " + host + " to be a known environment"); + } + } + + @Test + void isKnownEnvironment_unknownHost_returnsFalse() { + assertFalse(KnownMetadataProvider.isKnownEnvironment("custom.authority.example.com")); + } + + @Test + void getMetadataEntry_publicCloud_returnsCorrectAliases() { + // Arrange / Act + InstanceDiscoveryMetadataEntry entry = KnownMetadataProvider.getMetadataEntry("login.microsoftonline.com"); + + // Assert + assertNotNull(entry); + assertEquals("login.microsoftonline.com", entry.preferredNetwork()); + assertEquals("login.windows.net", entry.preferredCache()); + assertEquals(4, entry.aliases().size()); + assertTrue(entry.aliases().contains("login.microsoftonline.com")); + assertTrue(entry.aliases().contains("login.windows.net")); + assertTrue(entry.aliases().contains("login.microsoft.com")); + assertTrue(entry.aliases().contains("sts.windows.net")); + } + + @Test + void getMetadataEntry_publicCloudAliases_resolvesToSameEntry() { + // All public cloud aliases should resolve to the same object + InstanceDiscoveryMetadataEntry entry1 = KnownMetadataProvider.getMetadataEntry("login.microsoftonline.com"); + InstanceDiscoveryMetadataEntry entry2 = KnownMetadataProvider.getMetadataEntry("login.windows.net"); + InstanceDiscoveryMetadataEntry entry3 = KnownMetadataProvider.getMetadataEntry("login.microsoft.com"); + InstanceDiscoveryMetadataEntry entry4 = KnownMetadataProvider.getMetadataEntry("sts.windows.net"); + + // Assert + assertSame(entry1, entry2); + assertSame(entry2, entry3); + assertSame(entry3, entry4); + } + + @Test + void getMetadataEntry_unknownHost_returnsNull() { + assertNull(KnownMetadataProvider.getMetadataEntry("custom.authority.example.com")); + } + + @Test + void getMetadataEntry_caseInsensitive() { + // Assert — lookups should be case-insensitive + assertNotNull(KnownMetadataProvider.getMetadataEntry("LOGIN.MICROSOFTONLINE.COM")); + assertNotNull(KnownMetadataProvider.getMetadataEntry("Login.Partner.Microsoftonline.Cn")); + assertNotNull(KnownMetadataProvider.getMetadataEntry("LOGIN.SOVCLOUD-IDENTITY.FR")); + } + + @Test + void cacheInstanceDiscoveryMetadata_knownHost_cachesAllAliases() { + // Arrange + AadInstanceDiscoveryProvider.cache.clear(); + + // Act + AadInstanceDiscoveryProvider.cacheInstanceDiscoveryMetadata("login.microsoftonline.com"); + + // Assert — all public cloud aliases should be cached + assertNotNull(AadInstanceDiscoveryProvider.cache.get("login.microsoftonline.com")); + assertNotNull(AadInstanceDiscoveryProvider.cache.get("login.windows.net")); + assertNotNull(AadInstanceDiscoveryProvider.cache.get("login.microsoft.com")); + assertNotNull(AadInstanceDiscoveryProvider.cache.get("sts.windows.net")); + + // All should reference the same entry + InstanceDiscoveryMetadataEntry entry = AadInstanceDiscoveryProvider.cache.get("login.microsoftonline.com"); + assertSame(entry, AadInstanceDiscoveryProvider.cache.get("login.windows.net")); + assertSame(entry, AadInstanceDiscoveryProvider.cache.get("login.microsoft.com")); + assertSame(entry, AadInstanceDiscoveryProvider.cache.get("sts.windows.net")); + } + + @Test + void cacheInstanceDiscoveryMetadata_unknownHost_cachesSelfEntry() { + // Arrange + AadInstanceDiscoveryProvider.cache.clear(); + + // Act + AadInstanceDiscoveryProvider.cacheInstanceDiscoveryMetadata("custom.unknown.example.com"); + + // Assert — should get a self-referencing entry + InstanceDiscoveryMetadataEntry entry = AadInstanceDiscoveryProvider.cache.get("custom.unknown.example.com"); + assertNotNull(entry); + assertEquals("custom.unknown.example.com", entry.preferredNetwork()); + assertEquals("custom.unknown.example.com", entry.preferredCache()); + assertEquals(1, entry.aliases().size()); + assertTrue(entry.aliases().contains("custom.unknown.example.com")); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java new file mode 100644 index 00000000..4f45515b --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Acceptance tests for sovereign cloud instance discovery behavior. + * + * These tests create a real ConfidentialClientApplication with a sovereign authority, + * mock only the HTTP layer, and verify that all HTTP requests are routed to the + * correct sovereign host — not to login.microsoftonline.com or any other host. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SovereignCloudInstanceDiscoveryTest { + + private static final String SOVEREIGN_HOST = "login.sovcloud-identity.fr"; + private static final String SOVEREIGN_AUTHORITY = "https://" + SOVEREIGN_HOST + "/my-tenant"; + private static final String CLIENT_ID = "test-client-id"; + private static final String CLIENT_SECRET = "test-client-secret"; + + // A valid instance discovery response for the sovereign cloud + private static final String SOVEREIGN_INSTANCE_DISCOVERY_RESPONSE = "{" + + "\"tenant_discovery_endpoint\":\"https://" + SOVEREIGN_HOST + "/my-tenant/.well-known/openid-configuration\"," + + "\"api-version\":\"1.1\"," + + "\"metadata\":[{" + + "\"preferred_network\":\"" + SOVEREIGN_HOST + "\"," + + "\"preferred_cache\":\"" + SOVEREIGN_HOST + "\"," + + "\"aliases\":[\"" + SOVEREIGN_HOST + "\"]" + + "}]}"; + + @BeforeEach + void setup() { + AadInstanceDiscoveryProvider.cache.clear(); + } + + @Test + void sovereignAuthority_allRequestsRouteToSovereignHost() throws Exception { + // Arrange — mock HTTP client that captures all request URLs + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + String url = request.url().toString(); + + if (url.contains("discovery/instance")) { + return TestHelper.expectedResponse(200, SOVEREIGN_INSTANCE_DISCOVERY_RESPONSE); + } + if (url.contains("oauth2/v2.0/token")) { + return TestHelper.expectedResponse(200, + TestHelper.getSuccessfulTokenResponse(new HashMap<>())); + } + + fail("Unexpected request URL: " + url); + return null; + }); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret(CLIENT_SECRET)) + .authority(SOVEREIGN_AUTHORITY) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + // Act + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://resource/.default")).build() + ).get(); + + // Assert — token was acquired + assertNotNull(result); + assertNotNull(result.accessToken()); + + // Assert — every HTTP request went to the sovereign host + verify(httpClientMock, atLeastOnce()).send(requestCaptor.capture()); + List capturedRequests = requestCaptor.getAllValues(); + assertFalse(capturedRequests.isEmpty(), "At least one HTTP request should have been made"); + + for (HttpRequest req : capturedRequests) { + assertEquals(SOVEREIGN_HOST, req.url().getHost(), + "All requests must go to " + SOVEREIGN_HOST + ", but got: " + req.url()); + } + } + + @Test + void sovereignAuthority_instanceDiscoveryEndpointUsesOwnHost() throws Exception { + // Arrange — track which URLs are called + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + List capturedUrls = new ArrayList<>(); + + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + String url = request.url().toString(); + capturedUrls.add(url); + + if (url.contains("discovery/instance")) { + return TestHelper.expectedResponse(200, SOVEREIGN_INSTANCE_DISCOVERY_RESPONSE); + } + if (url.contains("oauth2/v2.0/token")) { + return TestHelper.expectedResponse(200, + TestHelper.getSuccessfulTokenResponse(new HashMap<>())); + } + + fail("Unexpected request URL: " + url); + return null; + }); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret(CLIENT_SECRET)) + .authority(SOVEREIGN_AUTHORITY) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + // Act + cca.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://resource/.default")).build() + ).get(); + + // Assert — instance discovery endpoint was called on the sovereign host + assertTrue(capturedUrls.stream().anyMatch(url -> + url.contains(SOVEREIGN_HOST) && url.contains("discovery/instance")), + "Instance discovery should be sent to " + SOVEREIGN_HOST + ". Captured URLs: " + capturedUrls); + + // Assert — no request went to the public cloud + assertTrue(capturedUrls.stream().noneMatch(url -> url.contains("login.microsoftonline.com")), + "No requests should go to login.microsoftonline.com. Captured URLs: " + capturedUrls); + } + + @Test + void sovereignAuthority_instanceDiscoveryFailure_usesKnownMetadataFallback() throws Exception { + // Arrange — instance discovery returns a server error (not invalid_instance) + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + List capturedUrls = new ArrayList<>(); + + String serverErrorResponse = "{\"error\":\"server_error\",\"error_description\":\"Internal server error\"}"; + + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + String url = request.url().toString(); + capturedUrls.add(url); + + if (url.contains("discovery/instance")) { + // Return 500 server error — should NOT throw, should fall back + return TestHelper.expectedResponse(500, serverErrorResponse); + } + if (url.contains("oauth2/v2.0/token")) { + return TestHelper.expectedResponse(200, + TestHelper.getSuccessfulTokenResponse(new HashMap<>())); + } + + fail("Unexpected request URL: " + url); + return null; + }); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret(CLIENT_SECRET)) + .authority(SOVEREIGN_AUTHORITY) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + // Act — should succeed despite instance discovery failure + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://resource/.default")).build() + ).get(); + + // Assert — token was still acquired + assertNotNull(result); + + // Assert — all requests went to the sovereign host + assertTrue(capturedUrls.stream().allMatch(url -> url.contains(SOVEREIGN_HOST)), + "All requests must go to " + SOVEREIGN_HOST + ". Captured URLs: " + capturedUrls); + + // Assert — known metadata for the sovereign host was cached + InstanceDiscoveryMetadataEntry cached = AadInstanceDiscoveryProvider.cache.get(SOVEREIGN_HOST); + assertNotNull(cached, "Fallback metadata should be cached for " + SOVEREIGN_HOST); + assertEquals(SOVEREIGN_HOST, cached.preferredNetwork()); + } + + @Test + void sovereignAuthority_invalidInstance_throws() throws Exception { + // Arrange — instance discovery returns invalid_instance error + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + String invalidInstanceResponse = "{\"error\":\"invalid_instance\"," + + "\"error_description\":\"AADSTS50049: Unknown or invalid instance.\"," + + "\"error_codes\":[50049]}"; + + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + String url = request.url().toString(); + + if (url.contains("discovery/instance")) { + return TestHelper.expectedResponse(400, invalidInstanceResponse); + } + + fail("No further requests should be made after invalid_instance. URL: " + url); + return null; + }); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret(CLIENT_SECRET)) + .authority(SOVEREIGN_AUTHORITY) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + // Act / Assert — should throw MsalServiceException + Exception thrown = assertThrows(Exception.class, () -> + cca.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://resource/.default")).build() + ).get() + ); + + // The CompletableFuture wraps the exception in ExecutionException + Throwable cause = thrown.getCause(); + assertInstanceOf(MsalServiceException.class, cause, + "Should throw MsalServiceException for invalid_instance, got: " + cause); + } + + @Test + void sovereignAuthority_networkException_cachesFallbackAndProceeds() throws Exception { + // Arrange — instance discovery throws a network exception on first call, + // then succeeds for the token request + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + List capturedUrls = new ArrayList<>(); + + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + String url = request.url().toString(); + capturedUrls.add(url); + + if (url.contains("discovery/instance")) { + throw new java.net.SocketException("Network is unreachable"); + } + if (url.contains("oauth2/v2.0/token")) { + return TestHelper.expectedResponse(200, + TestHelper.getSuccessfulTokenResponse(new HashMap<>())); + } + + fail("Unexpected request URL: " + url); + return null; + }); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret(CLIENT_SECRET)) + .authority(SOVEREIGN_AUTHORITY) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + // Act — should succeed despite network failure on instance discovery + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://resource/.default")).build() + ).get(); + + // Assert — token was acquired + assertNotNull(result); + + // Assert — all requests went to the sovereign host + assertTrue(capturedUrls.stream().allMatch(url -> url.contains(SOVEREIGN_HOST)), + "All requests must go to " + SOVEREIGN_HOST + ". Captured URLs: " + capturedUrls); + + // Assert — fallback entry was cached with known metadata + InstanceDiscoveryMetadataEntry cached = AadInstanceDiscoveryProvider.cache.get(SOVEREIGN_HOST); + assertNotNull(cached, "Fallback entry should be cached for " + SOVEREIGN_HOST); + assertEquals(SOVEREIGN_HOST, cached.preferredNetwork()); + } + + @Test + void sovereignAuthority_nonInvalidInstanceServiceError_doesNotThrow() throws Exception { + // Arrange — instance discovery returns a 502 with a non-invalid_instance error. + // This should NOT throw (unlike invalid_instance) — it should fall back gracefully. + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + String badGatewayResponse = "{\"error\":\"server_error\",\"error_description\":\"Bad Gateway\"}"; + + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + String url = request.url().toString(); + + if (url.contains("discovery/instance")) { + return TestHelper.expectedResponse(502, badGatewayResponse); + } + if (url.contains("oauth2/v2.0/token")) { + return TestHelper.expectedResponse(200, + TestHelper.getSuccessfulTokenResponse(new HashMap<>())); + } + + fail("Unexpected request URL: " + url); + return null; + }); + + ConfidentialClientApplication cca = ConfidentialClientApplication + .builder(CLIENT_ID, ClientCredentialFactory.createFromSecret(CLIENT_SECRET)) + .authority(SOVEREIGN_AUTHORITY) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + // Act — should NOT throw, should fall back + IAuthenticationResult result = assertDoesNotThrow(() -> + cca.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://resource/.default")).build() + ).get() + ); + + // Assert + assertNotNull(result); + assertNotNull(result.accessToken()); + } +}