Skip to content

Commit 13bd94b

Browse files
authored
Security: Harden B2C resource server token validation defaults (#49252)
* Security: harden B2C resource server token validation * docs: update changelog for B2C security hardening (PR #49252) * test: add comprehensive test coverage for reserved tenant ID rejection and normalization
1 parent 0699467 commit 13bd94b

5 files changed

Lines changed: 132 additions & 4 deletions

File tree

sdk/spring/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This section includes changes in `spring-cloud-azure-autoconfigure` module.
1010

1111
- 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))
1212
- `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))
13+
- 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))
1314

1415
#### Bugs Fixed
1516

sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/jwt/AadTrustedIssuerRepository.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,21 @@ public class AadTrustedIssuerRepository {
5353
* @param tenantId the tenant ID
5454
*/
5555
public AadTrustedIssuerRepository(String tenantId) {
56+
this(tenantId, true);
57+
}
58+
59+
/**
60+
* Creates a new instance of {@link AadTrustedIssuerRepository} with optional AAD issuer defaults.
61+
*
62+
* @param tenantId the tenant ID
63+
* @param includeAadIssuers whether to include default AAD trusted issuers
64+
*/
65+
protected AadTrustedIssuerRepository(String tenantId, boolean includeAadIssuers) {
5666
this.tenantId = tenantId;
57-
trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER));
58-
trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER_V2));
67+
if (includeAadIssuers) {
68+
trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER));
69+
trustedIssuers.addAll(buildAadIssuers(PATH_DELIMITER_V2));
70+
}
5971
}
6072

6173
private List<String> buildAadIssuers(String delimiter) {

sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
import java.util.ArrayList;
3737
import java.util.List;
38+
import java.util.Locale;
3839

3940
/**
4041
* 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 {
5859
@Bean
5960
@ConditionalOnMissingBean
6061
AadTrustedIssuerRepository trustedIssuerRepository() {
62+
validateTenantId(getTrimmedTenantId(properties));
6163
return new AadB2cTrustedIssuerRepository(properties);
6264
}
6365

@@ -93,19 +95,49 @@ JwtDecoder jwtDecoder(JWTProcessor<SecurityContext> jwtProcessor,
9395
NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
9496
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
9597
List<String> validAudiences = new ArrayList<>();
98+
String tenantId = getTrimmedTenantId(properties);
99+
validateTenantId(tenantId);
96100
if (StringUtils.hasText(properties.getAppIdUri())) {
97101
validAudiences.add(properties.getAppIdUri());
98102
}
99103
if (StringUtils.hasText(properties.getCredential().getClientId())) {
100104
validAudiences.add(properties.getCredential().getClientId());
101105
}
102106
if (!validAudiences.isEmpty()) {
103-
validators.add(new JwtClaimValidator<List<String>>(AadJwtClaimNames.AUD, validAudiences::containsAll));
107+
validators.add(new JwtClaimValidator<List<String>>(AadJwtClaimNames.AUD,
108+
audiences -> audiences != null
109+
&& !audiences.isEmpty()
110+
&& audiences.stream().anyMatch(validAudiences::contains)));
104111
}
112+
validators.add(new JwtClaimValidator<String>(AadJwtClaimNames.TID, tenantId::equals));
105113
validators.add(new AadJwtIssuerValidator(trustedIssuerRepository));
106114
validators.add(new JwtTimestampValidator());
107115
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
108116
return decoder;
109117
}
118+
119+
private static String getTrimmedTenantId(AadB2cProperties aadB2cProperties) {
120+
String tenantId = aadB2cProperties.getProfile().getTenantId();
121+
return tenantId != null ? tenantId.trim().toLowerCase(Locale.ROOT) : null;
122+
}
123+
124+
private static void validateTenantId(String tenantId) {
125+
if (!StringUtils.hasText(tenantId)
126+
|| "common".equalsIgnoreCase(tenantId)
127+
|| "organizations".equalsIgnoreCase(tenantId)
128+
|| "consumers".equalsIgnoreCase(tenantId)) {
129+
throw new IllegalArgumentException(
130+
"For B2C resource server, "
131+
+ "'spring.cloud.azure.active-directory.b2c.profile.tenant-id' "
132+
+ "cannot be null, empty, or set to 'common', "
133+
+ "'organizations', or 'consumers'. "
134+
+ "These values are not supported for resource server token "
135+
+ "validation because a specific tenant ID is required to "
136+
+ "validate the token 'tid' claim and issuer against a "
137+
+ "single B2C tenant. "
138+
+ "Please configure an explicit tenant ID for your "
139+
+ "organization's tenant.");
140+
}
141+
}
110142
}
111143

sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/security/jwt/AadB2cTrustedIssuerRepository.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,19 @@ public class AadB2cTrustedIssuerRepository extends AadTrustedIssuerRepository {
2727
* @param aadB2cProperties the AAD B2C properties
2828
*/
2929
public AadB2cTrustedIssuerRepository(AadB2cProperties aadB2cProperties) {
30-
super(aadB2cProperties.getProfile().getTenantId());
30+
super(getTrimmedTenantId(aadB2cProperties), false);
3131
this.aadB2cProperties = aadB2cProperties;
3232
this.resolvedBaseUri = resolveBaseUri(this.aadB2cProperties.getBaseUri());
3333
this.userFlows = this.aadB2cProperties.getUserFlows();
3434
this.addB2cIssuer();
3535
this.addB2cUserFlowIssuers();
3636
}
3737

38+
private static String getTrimmedTenantId(AadB2cProperties aadB2cProperties) {
39+
String tenantId = aadB2cProperties != null ? aadB2cProperties.getProfile().getTenantId() : null;
40+
return tenantId != null ? tenantId.trim().toLowerCase(ROOT) : null;
41+
}
42+
3843
private void addB2cIssuer() {
3944
Assert.notNull(aadB2cProperties, "aadB2cProperties cannot be null.");
4045
Assert.notNull(resolvedBaseUri, "resolvedBaseUri cannot be null.");

sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import org.springframework.security.oauth2.jwt.JwtDecoder;
3030
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
3131

32+
import java.util.Set;
33+
3234
import static org.assertj.core.api.Assertions.assertThat;
3335
import static org.mockito.ArgumentMatchers.any;
3436
import static org.mockito.Mockito.atLeastOnce;
@@ -182,6 +184,82 @@ void testExistAADB2CTrustedIssuerRepositoryBean() {
182184
context.getBean(AadB2cTrustedIssuerRepository.class);
183185
assertThat(aadb2CTrustedIssuerRepository).isNotNull();
184186
assertThat(aadb2CTrustedIssuerRepository).isExactlyInstanceOf(AadB2cTrustedIssuerRepository.class);
187+
188+
Set<String> trustedIssuers = aadb2CTrustedIssuerRepository.getTrustedIssuers();
189+
assertThat(trustedIssuers)
190+
.noneMatch(issuer -> issuer.startsWith("https://login.microsoftonline.com/"))
191+
.noneMatch(issuer -> issuer.startsWith("https://sts.windows.net/"))
192+
.noneMatch(issuer -> issuer.startsWith("https://sts.chinacloudapi.cn/"));
193+
});
194+
}
195+
196+
@Test
197+
void testValidateTenantIdRejectsCommon() {
198+
getDefaultContextRunner()
199+
.withPropertyValues(getB2CResourceServerProperties())
200+
.withPropertyValues(String.format("%s=common", AadB2cConstants.TENANT_ID))
201+
.withUserConfiguration(AadB2cResourceServerAutoConfiguration.class)
202+
.run(context -> {
203+
assertThat(context).hasFailed();
204+
assertThat(context.getStartupFailure())
205+
.hasRootCauseInstanceOf(IllegalArgumentException.class)
206+
.hasMessageContaining("cannot be null, empty, or set to");
207+
});
208+
}
209+
210+
@Test
211+
void testValidateTenantIdRejectsEmptyString() {
212+
getDefaultContextRunner()
213+
.withPropertyValues(getB2CResourceServerProperties())
214+
.withPropertyValues(String.format("%s=", AadB2cConstants.TENANT_ID))
215+
.withUserConfiguration(AadB2cResourceServerAutoConfiguration.class)
216+
.run(context -> {
217+
assertThat(context).hasFailed();
218+
assertThat(context.getStartupFailure())
219+
.hasRootCauseInstanceOf(IllegalArgumentException.class)
220+
.hasMessageContaining("cannot be null, empty, or set to");
221+
});
222+
}
223+
224+
@Test
225+
void testValidateTenantIdRejectsOrganizations() {
226+
getDefaultContextRunner()
227+
.withPropertyValues(getB2CResourceServerProperties())
228+
.withPropertyValues(String.format("%s=organizations", AadB2cConstants.TENANT_ID))
229+
.withUserConfiguration(AadB2cResourceServerAutoConfiguration.class)
230+
.run(context -> {
231+
assertThat(context).hasFailed();
232+
assertThat(context.getStartupFailure())
233+
.hasRootCauseInstanceOf(IllegalArgumentException.class)
234+
.hasMessageContaining("cannot be null, empty, or set to");
235+
});
236+
}
237+
238+
@Test
239+
void testValidateTenantIdRejectsConsumers() {
240+
getDefaultContextRunner()
241+
.withPropertyValues(getB2CResourceServerProperties())
242+
.withPropertyValues(String.format("%s=consumers", AadB2cConstants.TENANT_ID))
243+
.withUserConfiguration(AadB2cResourceServerAutoConfiguration.class)
244+
.run(context -> {
245+
assertThat(context).hasFailed();
246+
assertThat(context.getStartupFailure())
247+
.hasRootCauseInstanceOf(IllegalArgumentException.class)
248+
.hasMessageContaining("cannot be null, empty, or set to");
249+
});
250+
}
251+
252+
@Test
253+
void testValidateTenantIdRejectsReservedValuesWithWhitespaceAndCase() {
254+
getDefaultContextRunner()
255+
.withPropertyValues(getB2CResourceServerProperties())
256+
.withPropertyValues(String.format("%s= COMMON ", AadB2cConstants.TENANT_ID))
257+
.withUserConfiguration(AadB2cResourceServerAutoConfiguration.class)
258+
.run(context -> {
259+
assertThat(context).hasFailed();
260+
assertThat(context.getStartupFailure())
261+
.hasRootCauseInstanceOf(IllegalArgumentException.class)
262+
.hasMessageContaining("cannot be null, empty, or set to");
185263
});
186264
}
187265

0 commit comments

Comments
 (0)