Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/spring/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This section includes changes in `spring-cloud-azure-autoconfigure` module.

#### Bugs Fixed

- Fixed the AAD authentication filter (`AadAuthenticationFilter` and `AadAppRoleStatelessAuthenticationFilter`) not validating the `tid` (tenant ID) claim in JWT tokens against the configured tenant, allowing tokens from other tenants to be accepted. The JWT token validator now validates that the token's `tid` claim matches the configured tenant ID, preventing cross-tenant authentication bypass. This hardening is only enforced when a specific tenant ID is configured. ([#49631](https://github.com/Azure/azure-sdk-for-java/pull/49631))
- Fixed the AAD and B2C OpenID Connect login (`oauth2Login`) ID token decoders not validating the `iss` (issuer) and `aud` (audience) claims. `AadOidcIdTokenDecoderFactory` and `AadB2cOidcIdTokenDecoderFactory` now validate the standard OIDC ID token claims (audience, expiry, issued-at and subject) and the issuer. For single tenant applications the issuer must belong to the configured tenant, and for multi-tenant applications (the `common`, `organizations` or `consumers` endpoints) the issuer must be a trusted Microsoft identity platform issuer consistent with the token's own `tid` claim. This prevents users from unauthorized tenants from signing in to multi-tenant applications that rely on the issuer/tenant claim for tenant restriction ([#49423](https://github.com/Azure/azure-sdk-for-java/pull/49423)).
- Fixed the missing bean name in `@ConditionalOnMissingBean` for `LettuceClientConfigurationBuilderCustomizer` ([#49290](https://github.com/Azure/azure-sdk-for-java/issues/49290)).
- Fixed the AAD and B2C resource server JWT decoder not honoring the `spring.cloud.azure.active-directory.jwt-connect-timeout`, `spring.cloud.azure.active-directory.jwt-read-timeout`, `spring.cloud.azure.active-directory.b2c.jwt-connect-timeout`, and `spring.cloud.azure.active-directory.b2c.jwt-read-timeout` configuration properties ([#49329](https://github.com/Azure/azure-sdk-for-java/pull/49329)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;

import java.net.MalformedURLException;
import java.net.URL;
Expand Down Expand Up @@ -68,6 +69,21 @@ public UserPrincipalManager(JWKSource<SecurityContext> keySource) {
this.aadAuthenticationProperties = null;
}

/**
* Creates a new {@link UserPrincipalManager} with a predefined {@link JWKSource} and AAD authentication properties.
* <p>
* Package-private constructor for unit testing. This avoids reflective mutation of final fields and
* allows tests to inject both the JWK source and authentication properties without modifying private state.
*
* @param keySource - {@link JWKSource} containing at least one key
* @param aadAuthenticationProperties - AAD authentication properties for tenant validation
*/
UserPrincipalManager(JWKSource<SecurityContext> keySource, AadAuthenticationProperties aadAuthenticationProperties) {
this.keySource = keySource;
this.aadAuthenticationProperties = aadAuthenticationProperties;
this.explicitAudienceCheck = false;
}

/**
* Create a new {@link UserPrincipalManager} based of the
* {@link AadAuthorizationServerEndpoints#getJwkSetEndpoint()}
Expand Down Expand Up @@ -223,8 +239,38 @@ public void verify(JWTClaimsSet claimsSet, SecurityContext ctx) throws BadJWTExc
+ " does not match either the client-id or AppIdUri.");
}
}
// Validate tenant ID claim if tenant is configured (skip for multi-tenant values)
if (aadAuthenticationProperties != null) {
String configuredTenantId = aadAuthenticationProperties.getProfile().getTenantId();
if (StringUtils.hasText(configuredTenantId)) {
// Skip validation for multi-tenant values: common, organizations, consumers
String trimmedTenantId = configuredTenantId.trim().toLowerCase(java.util.Locale.ROOT);
if (!isMultiTenantValue(trimmedTenantId)) {
Object tidClaim = claimsSet.getClaim(AadJwtClaimNames.TID);
String tokenTid = tidClaim != null ? tidClaim.toString() : null;
String normalizedTokenTid = tokenTid != null
? tokenTid.trim().toLowerCase(java.util.Locale.ROOT)
: null;
if (!trimmedTenantId.equals(normalizedTokenTid)) {
throw new BadJWTException("Invalid token tenant. Token tid claim '" + tokenTid
+ "' does not match the configured tenant '" + configuredTenantId + "'.");
}
LOGGER.debug("Token tenant validated: [{}]", tokenTid);
}
}
}
}
});
return jwtProcessor;
}

/**
* Checks if the given tenant ID represents a multi-tenant configuration.
* Multi-tenant values (common, organizations, consumers) should skip tenant ID validation.
*/
private boolean isMultiTenantValue(String normalizedTenantId) {
return "common".equals(normalizedTenantId)
|| "organizations".equals(normalizedTenantId)
|| "consumers".equals(normalizedTenantId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@

package com.azure.spring.cloud.autoconfigure.implementation.aad.filter;

import com.azure.spring.cloud.autoconfigure.implementation.aad.configuration.properties.AadAuthenticationProperties;
import com.azure.spring.cloud.autoconfigure.implementation.aad.configuration.properties.AadProfileProperties;
import com.azure.spring.cloud.autoconfigure.implementation.aad.security.constants.AadJwtClaimNames;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.BadJWTException;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
Expand All @@ -29,6 +37,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand Down Expand Up @@ -87,6 +96,99 @@ void getRolesTest() {
rolesExtractedAsExpected(new HashSet<>(Arrays.asList("role1", "role2")), Arrays.asList("role1", "role2"));
}

@Test
void tenantIdValidationSucceedsWhenMatchingConfiguredTenant() throws Exception {
// Setup: create mocked AadAuthenticationProperties with configured tenant ID
AadAuthenticationProperties properties = Mockito.mock(AadAuthenticationProperties.class);
AadProfileProperties profileProperties = Mockito.mock(AadProfileProperties.class);
Mockito.when(properties.getProfile()).thenReturn(profileProperties);
Mockito.when(profileProperties.getTenantId()).thenReturn("test");

// Create UserPrincipalManager with both JWKSource and properties (no reflection needed)
userPrincipalManager = new UserPrincipalManager(immutableJWKSet, properties);

// Create JWT claims set with matching tenant ID
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer("https://sts.windows.net/test")
.claim(AadJwtClaimNames.TID, "test")
.build();

// Execute: get validator and verify claims - should NOT throw exception
ConfigurableJWTProcessor<SecurityContext> validator = getValidator(userPrincipalManager, null);
assertThatCode(() -> validator.getJWTClaimsSetVerifier().verify(claimsSet, null))
.doesNotThrowAnyException();
}

@Test
void tenantIdValidationFailsWhenMismatchedTenant() throws Exception {
// Setup: create mocked AadAuthenticationProperties with configured tenant ID "test"
AadAuthenticationProperties properties = Mockito.mock(AadAuthenticationProperties.class);
AadProfileProperties profileProperties = Mockito.mock(AadProfileProperties.class);
Mockito.when(properties.getProfile()).thenReturn(profileProperties);
Mockito.when(profileProperties.getTenantId()).thenReturn("test");

// Create UserPrincipalManager with both JWKSource and properties (no reflection needed)
userPrincipalManager = new UserPrincipalManager(immutableJWKSet, properties);

// Create JWT claims set with different tenant ID (mismatched)
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer("https://sts.windows.net/other-tenant-id")
.claim(AadJwtClaimNames.TID, "other-tenant-id")
.build();

// Execute: verification should throw BadJWTException
ConfigurableJWTProcessor<SecurityContext> validator = getValidator(userPrincipalManager, null);
assertThatThrownBy(() -> validator.getJWTClaimsSetVerifier().verify(claimsSet, null))
.isInstanceOf(BadJWTException.class)
.hasMessageContaining("Invalid token tenant");
}

@Test
void tenantIdValidationSkippedWhenNoTenantConfigured() throws Exception {
// Setup: create mocked AadAuthenticationProperties with default multi-tenant value "common"
AadAuthenticationProperties properties = Mockito.mock(AadAuthenticationProperties.class);
AadProfileProperties profileProperties = Mockito.mock(AadProfileProperties.class);
Mockito.when(properties.getProfile()).thenReturn(profileProperties);
Mockito.when(profileProperties.getTenantId()).thenReturn("common");

// Create UserPrincipalManager with both JWKSource and properties (no reflection needed)
userPrincipalManager = new UserPrincipalManager(immutableJWKSet, properties);

// Create JWT claims set with any tenant ID - should be accepted since "common" is multi-tenant
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer("https://sts.windows.net/any-tenant")
.claim(AadJwtClaimNames.TID, "any-tenant")
.build();

// Execute: verification should NOT throw exception for multi-tenant config
ConfigurableJWTProcessor<SecurityContext> validator = getValidator(userPrincipalManager, null);
assertThatCode(() -> validator.getJWTClaimsSetVerifier().verify(claimsSet, null))
.doesNotThrowAnyException();
}

@Test
void tenantIdValidationSkippedWhenOrganizationsConfigured() throws Exception {
// Setup: create mocked AadAuthenticationProperties with multi-tenant value "organizations"
AadAuthenticationProperties properties = Mockito.mock(AadAuthenticationProperties.class);
AadProfileProperties profileProperties = Mockito.mock(AadProfileProperties.class);
Mockito.when(properties.getProfile()).thenReturn(profileProperties);
Mockito.when(profileProperties.getTenantId()).thenReturn("organizations");

// Create UserPrincipalManager with both JWKSource and properties (no reflection needed)
userPrincipalManager = new UserPrincipalManager(immutableJWKSet, properties);

// Create JWT claims set with any tenant ID
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.issuer("https://sts.windows.net/any-tenant")
.claim(AadJwtClaimNames.TID, "any-tenant")
.build();

// Execute: verification should NOT throw exception for multi-tenant config
ConfigurableJWTProcessor<SecurityContext> validator = getValidator(userPrincipalManager, null);
assertThatCode(() -> validator.getJWTClaimsSetVerifier().verify(claimsSet, null))
.doesNotThrowAnyException();
}

private void rolesExtractedAsExpected(Object rolesClaimValue, Collection<String> expected) {
JWTClaimsSet set = new JWTClaimsSet.Builder()
.claim("roles", rolesClaimValue)
Expand All @@ -97,6 +199,21 @@ private void rolesExtractedAsExpected(Object rolesClaimValue, Collection<String>
assertTrue(actual.containsAll(expected));
}

/**
* Helper method to invoke the private getValidator method via reflection.
* This allows us to test the validator logic without making network calls.
*/
@SuppressWarnings("unchecked")
private ConfigurableJWTProcessor<SecurityContext> getValidator(UserPrincipalManager manager,
JWSAlgorithm algorithm) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Method method = UserPrincipalManager.class.getDeclaredMethod("getValidator",
JWSAlgorithm.class);
method.setAccessible(true);
// Use RS256 as default algorithm if null
JWSAlgorithm alg = (algorithm != null) ? algorithm : JWSAlgorithm.RS256;
return (ConfigurableJWTProcessor<SecurityContext>) method.invoke(manager, alg);
}

private String readJwtValidIssuerTxt() {
return readFileToString("src/test/resources/aad/jwt-null-issuer.txt");
}
Expand Down