From ebba94f98f7b51bb14a1a83f39e9248f223993d6 Mon Sep 17 00:00:00 2001 From: Rujun Chen Date: Mon, 25 May 2026 09:50:05 +0800 Subject: [PATCH 1/3] Security: harden B2C resource server token validation --- .../jwt/AadTrustedIssuerRepository.java | 16 +++++++-- ...AadB2cResourceServerAutoConfiguration.java | 34 +++++++++++++++++- .../jwt/AadB2cTrustedIssuerRepository.java | 7 +++- ...cResourceServerAutoConfigurationTests.java | 36 +++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/jwt/AadTrustedIssuerRepository.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/jwt/AadTrustedIssuerRepository.java index 7255c8278ef0..0485bb8711e4 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/jwt/AadTrustedIssuerRepository.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/jwt/AadTrustedIssuerRepository.java @@ -53,9 +53,21 @@ public class AadTrustedIssuerRepository { * @param tenantId the tenant ID */ public AadTrustedIssuerRepository(String tenantId) { + this(tenantId, true); + } + + /** + * Creates a new instance of {@link AadTrustedIssuerRepository} with optional AAD issuer defaults. + * + * @param tenantId the tenant ID + * @param includeAadIssuers whether to include default AAD trusted issuers + */ + protected AadTrustedIssuerRepository(String tenantId, boolean includeAadIssuers) { this.tenantId = tenantId; - trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER)); - trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER_V2)); + if (includeAadIssuers) { + trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER)); + trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER_V2)); + } } private List buildAadIssuers(String delimiter) { diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java index a35b2c75065a..0d49961eb723 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java @@ -35,6 +35,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; /** * Configure necessary beans for Azure AD B2C resource server beans, and import {@link AadB2cOAuth2ClientConfiguration} class for Azure AD @@ -58,6 +59,7 @@ public class AadB2cResourceServerAutoConfiguration { @Bean @ConditionalOnMissingBean AadTrustedIssuerRepository trustedIssuerRepository() { + validateTenantId(getTrimmedTenantId(properties)); return new AadB2cTrustedIssuerRepository(properties); } @@ -93,6 +95,8 @@ JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor); List> validators = new ArrayList<>(); List validAudiences = new ArrayList<>(); + String tenantId = getTrimmedTenantId(properties); + validateTenantId(tenantId); if (StringUtils.hasText(properties.getAppIdUri())) { validAudiences.add(properties.getAppIdUri()); } @@ -100,12 +104,40 @@ JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, validAudiences.add(properties.getCredential().getClientId()); } if (!validAudiences.isEmpty()) { - validators.add(new JwtClaimValidator>(AadJwtClaimNames.AUD, validAudiences::containsAll)); + validators.add(new JwtClaimValidator>(AadJwtClaimNames.AUD, + audiences -> audiences != null + && !audiences.isEmpty() + && audiences.stream().anyMatch(validAudiences::contains))); } + validators.add(new JwtClaimValidator(AadJwtClaimNames.TID, tenantId::equals)); validators.add(new AadJwtIssuerValidator(trustedIssuerRepository)); validators.add(new JwtTimestampValidator()); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators)); return decoder; } + + private static String getTrimmedTenantId(AadB2cProperties aadB2cProperties) { + String tenantId = aadB2cProperties.getProfile().getTenantId(); + return tenantId != null ? tenantId.trim().toLowerCase(Locale.ROOT) : null; + } + + private static void validateTenantId(String tenantId) { + if (!StringUtils.hasText(tenantId) + || "common".equalsIgnoreCase(tenantId) + || "organizations".equalsIgnoreCase(tenantId) + || "consumers".equalsIgnoreCase(tenantId)) { + throw new IllegalArgumentException( + "For B2C resource server, " + + "'spring.cloud.azure.active-directory.b2c.profile.tenant-id' " + + "cannot be null, empty, or set to 'common', " + + "'organizations', or 'consumers'. " + + "These values are not supported for resource server token " + + "validation because a specific tenant ID is required to " + + "validate the token 'tid' claim and issuer against a " + + "single B2C tenant. " + + "Please configure an explicit tenant ID for your " + + "organization's tenant."); + } + } } diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/security/jwt/AadB2cTrustedIssuerRepository.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/security/jwt/AadB2cTrustedIssuerRepository.java index 8b15a1a9f23b..317c293fe357 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/security/jwt/AadB2cTrustedIssuerRepository.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/security/jwt/AadB2cTrustedIssuerRepository.java @@ -27,7 +27,7 @@ public class AadB2cTrustedIssuerRepository extends AadTrustedIssuerRepository { * @param aadB2cProperties the AAD B2C properties */ public AadB2cTrustedIssuerRepository(AadB2cProperties aadB2cProperties) { - super(aadB2cProperties.getProfile().getTenantId()); + super(getTrimmedTenantId(aadB2cProperties), false); this.aadB2cProperties = aadB2cProperties; this.resolvedBaseUri = resolveBaseUri(this.aadB2cProperties.getBaseUri()); this.userFlows = this.aadB2cProperties.getUserFlows(); @@ -35,6 +35,11 @@ public AadB2cTrustedIssuerRepository(AadB2cProperties aadB2cProperties) { this.addB2cUserFlowIssuers(); } + private static String getTrimmedTenantId(AadB2cProperties aadB2cProperties) { + String tenantId = aadB2cProperties != null ? aadB2cProperties.getProfile().getTenantId() : null; + return tenantId != null ? tenantId.trim().toLowerCase(ROOT) : null; + } + private void addB2cIssuer() { Assert.notNull(aadB2cProperties, "aadB2cProperties cannot be null."); Assert.notNull(resolvedBaseUri, "resolvedBaseUri cannot be null."); diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java index 2418871d3d5d..69466f5ea392 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java @@ -29,6 +29,8 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import java.util.Set; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; @@ -182,6 +184,40 @@ void testExistAADB2CTrustedIssuerRepositoryBean() { context.getBean(AadB2cTrustedIssuerRepository.class); assertThat(aadb2CTrustedIssuerRepository).isNotNull(); assertThat(aadb2CTrustedIssuerRepository).isExactlyInstanceOf(AadB2cTrustedIssuerRepository.class); + + Set trustedIssuers = aadb2CTrustedIssuerRepository.getTrustedIssuers(); + assertThat(trustedIssuers) + .noneMatch(issuer -> issuer.startsWith("https://login.microsoftonline.com/")) + .noneMatch(issuer -> issuer.startsWith("https://sts.windows.net/")) + .noneMatch(issuer -> issuer.startsWith("https://sts.chinacloudapi.cn/")); + }); + } + + @Test + void testValidateTenantIdRejectsCommon() { + getDefaultContextRunner() + .withPropertyValues(getB2CResourceServerProperties()) + .withPropertyValues(String.format("%s=common", AadB2cConstants.TENANT_ID)) + .withUserConfiguration(AadB2cResourceServerAutoConfiguration.class) + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null, empty, or set to"); + }); + } + + @Test + void testValidateTenantIdRejectsEmptyString() { + getDefaultContextRunner() + .withPropertyValues(getB2CResourceServerProperties()) + .withPropertyValues(String.format("%s=", AadB2cConstants.TENANT_ID)) + .withUserConfiguration(AadB2cResourceServerAutoConfiguration.class) + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null, empty, or set to"); }); } From df3ddaeed5aa91e9ddbd1ee763936fd4515f706a Mon Sep 17 00:00:00 2001 From: Rujun Chen Date: Mon, 25 May 2026 09:55:39 +0800 Subject: [PATCH 2/3] docs: update changelog for B2C security hardening (PR #49252) --- sdk/spring/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md index 7c0d1465cdf8..e4491e4fde64 100644 --- a/sdk/spring/CHANGELOG.md +++ b/sdk/spring/CHANGELOG.md @@ -10,6 +10,7 @@ This section includes changes in `spring-cloud-azure-autoconfigure` module. - AAD resource server now requires `spring.cloud.azure.active-directory.profile.tenant-id` to be set to a specific (non-reserved) tenant ID. Empty string, `common`, `organizations`, and `consumers` are no longer accepted and will cause application startup to fail with an `IllegalArgumentException`. ([#49033](https://github.com/Azure/azure-sdk-for-java/pull/49033)) - `AadAuthenticationFilter` now enables explicit audience validation by default. The filter will verify that the JWT's `aud` (audience) claim matches either `spring.cloud.azure.active-directory.credential.client-id` or `spring.cloud.azure.active-directory.app-id-uri`. Tokens issued for other applications will be rejected with `BadJWTException`. This prevents cross-application token reuse and aligns with OAuth2/OIDC security best practices. ([#49033](https://github.com/Azure/azure-sdk-for-java/pull/49033)) +- B2C resource server now requires `spring.cloud.azure.active-directory.b2c.profile.tenant-id` to be set to a specific (non-reserved) tenant ID. Empty string, `common`, `organizations`, and `consumers` are no longer accepted. In addition, default token validation is hardened to enforce tenant-bound `tid`, stricter `aud` validation, and B2C-only trusted issuers. ([#49252](https://github.com/Azure/azure-sdk-for-java/pull/49252)) #### Bugs Fixed From a01c167996b4bb642bf7db3afaaf2ef9b44c758f Mon Sep 17 00:00:00 2001 From: Rujun Chen Date: Mon, 25 May 2026 10:08:06 +0800 Subject: [PATCH 3/3] test: add comprehensive test coverage for reserved tenant ID rejection and normalization --- ...cResourceServerAutoConfigurationTests.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java index 69466f5ea392..805dba9d8ede 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java @@ -221,6 +221,48 @@ void testValidateTenantIdRejectsEmptyString() { }); } + @Test + void testValidateTenantIdRejectsOrganizations() { + getDefaultContextRunner() + .withPropertyValues(getB2CResourceServerProperties()) + .withPropertyValues(String.format("%s=organizations", AadB2cConstants.TENANT_ID)) + .withUserConfiguration(AadB2cResourceServerAutoConfiguration.class) + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null, empty, or set to"); + }); + } + + @Test + void testValidateTenantIdRejectsConsumers() { + getDefaultContextRunner() + .withPropertyValues(getB2CResourceServerProperties()) + .withPropertyValues(String.format("%s=consumers", AadB2cConstants.TENANT_ID)) + .withUserConfiguration(AadB2cResourceServerAutoConfiguration.class) + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null, empty, or set to"); + }); + } + + @Test + void testValidateTenantIdRejectsReservedValuesWithWhitespaceAndCase() { + getDefaultContextRunner() + .withPropertyValues(getB2CResourceServerProperties()) + .withPropertyValues(String.format("%s= COMMON ", AadB2cConstants.TENANT_ID)) + .withUserConfiguration(AadB2cResourceServerAutoConfiguration.class) + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null, empty, or set to"); + }); + } + @Test @SuppressWarnings({ "unchecked", "rawtypes" }) void testExistjwtProcessorBean() {