Skip to content
Merged
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 @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> buildAadIssuers(String delimiter) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,6 +59,7 @@ public class AadB2cResourceServerAutoConfiguration {
@Bean
@ConditionalOnMissingBean
AadTrustedIssuerRepository trustedIssuerRepository() {
validateTenantId(getTrimmedTenantId(properties));
return new AadB2cTrustedIssuerRepository(properties);
}

Expand Down Expand Up @@ -93,19 +95,49 @@ JwtDecoder jwtDecoder(JWTProcessor<SecurityContext> jwtProcessor,
NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
List<String> validAudiences = new ArrayList<>();
String tenantId = getTrimmedTenantId(properties);
validateTenantId(tenantId);
if (StringUtils.hasText(properties.getAppIdUri())) {
validAudiences.add(properties.getAppIdUri());
}
if (StringUtils.hasText(properties.getCredential().getClientId())) {
validAudiences.add(properties.getCredential().getClientId());
}
if (!validAudiences.isEmpty()) {
validators.add(new JwtClaimValidator<List<String>>(AadJwtClaimNames.AUD, validAudiences::containsAll));
validators.add(new JwtClaimValidator<List<String>>(AadJwtClaimNames.AUD,
audiences -> audiences != null
&& !audiences.isEmpty()
&& audiences.stream().anyMatch(validAudiences::contains)));
}
validators.add(new JwtClaimValidator<String>(AadJwtClaimNames.TID, tenantId::equals));
validators.add(new AadJwtIssuerValidator(trustedIssuerRepository));
validators.add(new JwtTimestampValidator());
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
Comment thread
rujche marked this conversation as resolved.
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.");
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,19 @@ 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();
this.addB2cIssuer();
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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -182,6 +184,82 @@ void testExistAADB2CTrustedIssuerRepositoryBean() {
context.getBean(AadB2cTrustedIssuerRepository.class);
assertThat(aadb2CTrustedIssuerRepository).isNotNull();
assertThat(aadb2CTrustedIssuerRepository).isExactlyInstanceOf(AadB2cTrustedIssuerRepository.class);

Set<String> 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");
});
Comment thread
rujche marked this conversation as resolved.
}

@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");
});
}
Comment thread
rujche marked this conversation as resolved.

@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");
});
}

Expand Down