diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java index 63759f3b..9207e5de 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java @@ -8,8 +8,11 @@ public class OidcAuthority extends Authority { //Part of the OpenIdConnect standard, this is appended to the authority to create the endpoint that has OIDC metadata - static final String WELL_KNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; + private static final String WELL_KNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; private static final String AUTHORITY_FORMAT = "https://%s/%s/"; + private static final String CIAM_AUTHORITY_FORMAT = "https://%s.ciamlogin.com/%s"; + + private String issuerFromOidcDiscovery; OidcAuthority(URL authorityUrl) throws MalformedURLException { super(createOidcDiscoveryUrl(authorityUrl), AuthorityType.OIDC); @@ -29,5 +32,52 @@ void setAuthorityProperties(OidcDiscoveryResponse instanceDiscoveryResponse) { this.tokenEndpoint = instanceDiscoveryResponse.tokenEndpoint(); this.deviceCodeEndpoint = instanceDiscoveryResponse.deviceCodeEndpoint(); this.selfSignedJwtAudience = this.tokenEndpoint; + this.issuerFromOidcDiscovery = instanceDiscoveryResponse.issuer(); + + validateIssuer(); + } + + private void validateIssuer() { + if (!isIssuerValid()) { + throw new MsalClientException( + String.format("Invalid issuer from OIDC discovery. Issuer %s does not match authority %s, or is in an unexpected format", issuerFromOidcDiscovery, canonicalAuthorityUrl), + "issuer_validation"); + } + } + + /** + * Validates the issuer from OIDC discovery. + * Issuer is valid if it matches the authority URL (without the well-known segment) + * or if it follows the CIAM issuer format. + * + * @return true if the issuer is valid, false otherwise + */ + private boolean isIssuerValid() { + if (issuerFromOidcDiscovery == null) { + return false; + } + + // Case 1: Check against canonicalAuthorityUrl without the well-known segment + String authorityWithoutWellKnown = canonicalAuthorityUrl.toString(); + if (authorityWithoutWellKnown.endsWith(WELL_KNOWN_OPENID_CONFIGURATION)) { + authorityWithoutWellKnown = authorityWithoutWellKnown.substring(0, + authorityWithoutWellKnown.length() - WELL_KNOWN_OPENID_CONFIGURATION.length()); + + // Normalize both URLs to ensure consistent comparison + String normalizedAuthority = Authority.enforceTrailingSlash(authorityWithoutWellKnown); + String normalizedIssuer = Authority.enforceTrailingSlash(issuerFromOidcDiscovery); + + if (normalizedIssuer.equals(normalizedAuthority)) { + return true; + } + } + + // Case 2: Check CIAM format: "https://{tenant}.ciamlogin.com/{tenant}" + if (!StringHelper.isNullOrBlank(tenant)) { + String ciamPattern = String.format(CIAM_AUTHORITY_FORMAT, tenant, tenant); + return issuerFromOidcDiscovery.startsWith(ciamPattern); + } + + return false; } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java index 0e8d6fbb..d0e961b4 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java @@ -15,6 +15,7 @@ class OidcDiscoveryResponse implements JsonSerializable { private String authorizationEndpoint; private String tokenEndpoint; private String deviceCodeEndpoint; + private String issuer; public static OidcDiscoveryResponse fromJson(JsonReader jsonReader) throws IOException { OidcDiscoveryResponse response = new OidcDiscoveryResponse(); @@ -32,6 +33,9 @@ public static OidcDiscoveryResponse fromJson(JsonReader jsonReader) throws IOExc case "device_authorization_endpoint": response.deviceCodeEndpoint = reader.getString(); break; + case "issuer": + response.issuer = reader.getString(); + break; default: reader.skipChildren(); break; @@ -61,4 +65,8 @@ String tokenEndpoint() { String deviceCodeEndpoint() { return this.deviceCodeEndpoint; } + + String issuer() { + return this.issuer; + } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java index e7fc85f4..714f3947 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java @@ -124,4 +124,9 @@ static void setHttpClient(IHttpClient client) { httpClient = client; httpHelper = new HttpHelper(httpClient, new ManagedIdentityRetryPolicy()); } + + static void resetHttpClient() { + httpClient = new DefaultHttpClientManagedIdentity(null, null, null, null); + httpHelper = new HttpHelper(httpClient, new ManagedIdentityRetryPolicy()); + } } \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java index 1f982dbb..5a3fc90e 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java @@ -52,6 +52,11 @@ static void resetRetryPolicies() { IMDSRetryPolicy.resetToDefaults(); } + @AfterAll + static void resetServiceFabricHttpClient() { + ServiceFabricManagedIdentitySource.resetHttpClient(); + } + private String getSuccessfulResponse(String resource) { long expiresOn = (System.currentTimeMillis() / 1000) + (24 * 3600);//A long-lived, 24 hour token return "{\"access_token\":\"accesstoken\",\"expires_on\":\"" + expiresOn + "\",\"resource\":\"" + resource + "\",\"token_type\":" + diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OidcAuthorityTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OidcAuthorityTest.java new file mode 100644 index 00000000..d412d862 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OidcAuthorityTest.java @@ -0,0 +1,107 @@ +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.Mockito; + +import java.net.MalformedURLException; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OidcAuthorityTest { + + private OidcDiscoveryResponse mockDiscoveryResponse; + + @BeforeEach + void setup() { + mockDiscoveryResponse = Mockito.mock(OidcDiscoveryResponse.class); + when(mockDiscoveryResponse.authorizationEndpoint()).thenReturn("https://login.example.com/authorize"); + when(mockDiscoveryResponse.tokenEndpoint()).thenReturn("https://login.example.com/token"); + when(mockDiscoveryResponse.deviceCodeEndpoint()).thenReturn("https://login.example.com/devicecode"); + } + + @Test + void testSetAuthorityProperties_IssuerMatchesAuthority() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Match the issuer to the authority URL (without the well-known segment) + when(mockDiscoveryResponse.issuer()).thenReturn("https://login.example.com/tenant1/"); + + // Act & Assert - Should not throw exception + assertDoesNotThrow(() -> authority.setAuthorityProperties(mockDiscoveryResponse)); + + // Verify properties were set + assertEquals("https://login.example.com/authorize", authority.authorizationEndpoint()); + assertEquals("https://login.example.com/token", authority.tokenEndpoint()); + assertEquals("https://login.example.com/devicecode", authority.deviceCodeEndpoint()); + assertEquals("https://login.example.com/token", authority.selfSignedJwtAudience); + } + + @Test + void testSetAuthorityProperties_IssuerFollowsCiamPattern() throws MalformedURLException { + // Arrange + String tenant = "contoso"; + URL authorityUrl = new URL("https://" + tenant + ".ciamlogin.com/" + tenant + "/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Set an issuer that follows CIAM pattern but doesn't exactly match the authority + String ciamIssuer = "https://" + tenant + ".ciamlogin.com/" + tenant + "/v2.0"; + when(mockDiscoveryResponse.issuer()).thenReturn(ciamIssuer); + + // Act & Assert - Should not throw exception + assertDoesNotThrow(() -> authority.setAuthorityProperties(mockDiscoveryResponse)); + } + + @Test + void testSetAuthorityProperties_IssuerInvalid() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Set an issuer that doesn't match the authority and doesn't follow CIAM pattern + when(mockDiscoveryResponse.issuer()).thenReturn("https://login.different.com/tenant1/"); + + // Act & Assert - Should throw MsalClientException + MsalClientException exception = assertThrows(MsalClientException.class, + () -> authority.setAuthorityProperties(mockDiscoveryResponse)); + + // Verify exception details + assertEquals("issuer_validation", exception.errorCode()); + assertTrue(exception.getMessage().contains("Invalid issuer from OIDC discovery")); + } + + @Test + void testSetAuthorityProperties_IssuerIsNull() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Set null issuer + when(mockDiscoveryResponse.issuer()).thenReturn(null); + + // Act & Assert - Should throw MsalClientException + MsalClientException exception = assertThrows(MsalClientException.class, + () -> authority.setAuthorityProperties(mockDiscoveryResponse)); + + // Verify exception details + assertEquals("issuer_validation", exception.errorCode()); + assertTrue(exception.getMessage().contains("Invalid issuer from OIDC discovery")); + } + + @Test + void testSetAuthorityProperties_TrailingSlashNormalization() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Match the issuer to the authority but without trailing slash + when(mockDiscoveryResponse.issuer()).thenReturn("https://login.example.com/tenant1"); + + // Act & Assert - Should not throw exception because normalization happens + assertDoesNotThrow(() -> authority.setAuthorityProperties(mockDiscoveryResponse)); + } +} \ No newline at end of file