Reputation: 550
I'm trying to implement the ability to use Azure B2C with Spring Boot's Webflux Security. While there's no official library to actually do this, it was said by someone at Microsoft that Spring Security 5's native features could support Azure B2C. I've followed this repository (though it's not webflux based) to get an idea on pulling this off. The JWT tokens are validated via the audience UUID for an application.
Once I try to actually supply a JWT token to a request, I'm getting a HTTP 401 error stating Authentication failed: Failed to validate the token
.
The thing is that in the example repository, they're using the endpoint https://login.microsoftonline.com/{tenantId}/v2.0
for the issuer url.
On the other hand, the JWT token returned from B2C has the issuer https://{tenantName}.b2clogin.com/{tenantId}/v2.0/
.
If I use the issuer https://{tenantName}.b2clogin.com/{tenantId}/v2.0/
instead, the JWT decoder won't be able to find the configurations.
So now I feel there's an inconsistency on what the issuer URL actually is, which prevents Webflux from actually being able to perform the authentication.
Here's the code I have for the security.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.web.server.SecurityWebFilterChain;
@EnableWebFluxSecurity
public class SecurityConfiguration {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
String issuerUri;
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http)throws Exception {
return http.cors().and().csrf().disable()
.authorizeExchange()
.anyExchange()
.authenticated()
.and().oauth2ResourceServer().jwt().and().and().build();
}
@Bean
ReactiveJwtDecoder jwtDecoder() {
NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder) ReactiveJwtDecoders.fromIssuerLocation(issuerUri);
OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator();
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
jwtDecoder.setJwtValidator(withAudience);
return jwtDecoder;
}
}
The audience validator.
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
OAuth2Error error = new OAuth2Error("invalid_token", "The required audience is missing", null);
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains("messaging")) {
return OAuth2TokenValidatorResult.success();
} else {
return OAuth2TokenValidatorResult.failure(error);
}
}
}
application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://login.microsoftonline.com/{tenantId}/discovery/v2.0/keys
issuer-uri: https://login.microsoftonline.com/{tenantId}/v2.0
audience: {audience-id}
So, I have three questions:
Upvotes: 2
Views: 2249
Reputation: 1628
By default, this all works now if you want the complete standard settings and pull the values off of resource YML profile files. Here is the securityWebFilterChain for absolute barebones implementation, that includes doing all three checks (issuer, key validation and token expiration) :
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http
.cors().and()
.csrf().disable()
.authorizeExchange(exchanges -> exchanges
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(withDefaults())
);
return http.build();
}
}
Doing this implementation means you must have the proper application.yml file entries for those default resource locations. Specifically,
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://login.microsoftonline.com/{tenantId}/discovery/v2.0/keys
issuer-uri: https://login.microsoftonline.com/{tenantId}/v2.0
Everything on the above basic setup will work great, just so long as you are not behind a corporate firewall. If you are, you must find a way to include the proxy settings in the implementation. To the chagrin of all, for some reason the default implementation of this particular portion of Spring Security 5 does NOT honor JAVA_OPT settings related to proxies. To get around this, you must implement your own proxy overrides. Yes, I tried overriding the Proxy bean. And it is not honored either. The only way I could get it to work was to instantiate a web client with the proxy settings directly overridden as well.
This all means that one must inject a customerDecoder for the JWT method on oauth2, and inside that customer decoder, instantiate a webclient that has a custom client connector definition that explicitly defines its proxy settings. Of course, since you are overloading the decoder, you must redefine all the features it would normally include by default (including the aforementioned 3 types of token validation).
Yuck.
A lot of weirdness that should just be standard, if you ask me. Here is what I ended up with that works in both non-firewall and firewall situations:
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http
.cors().and()
.csrf().disable()
.authorizeExchange(exchanges -> exchanges
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt()
.jwtDecoder(this.customDecoder())
);
return http.build();
}
ReactiveJwtDecoder customDecoder() {
HttpClient httpClient =
HttpClient.create()
.proxy(proxy -> proxy
.type(ProxyProvider.Proxy.HTTP)
.host(proxyHost)
.port(proxyPort));
ReactorClientHttpConnector conn = new ReactorClientHttpConnector(httpClient);
// use a customized webClient with explicit proxy settings for the profile
final NimbusReactiveJwtDecoder userTokenDecoder = NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
.webClient(WebClient.builder().clientConnector(conn).build()).build();
// add both issuer and timestamp validators for JWT token
OAuth2TokenValidator<Jwt> jwtValidator =
JwtValidators.createDefaultWithIssuer(issuerUri);
userTokenDecoder.setJwtValidator(jwtValidator);
return userTokenDecoder;
}
Upvotes: 0
Reputation: 550
The issuer URL https://login.microsoftonline.com/{tenantId}/v2.0
is for Azure AD.
Because Azure B2C is dependent on profiles that are defined, you have to use https://{tenantName}.b2clogin.com/tfp/{tenantId}/{profileName}/v2.0/
as the issuer URL.
While https://{tenantName}.b2clogin.com/{tenantId}/{profileName}/v2.0/
is also a valid issuer, Spring Security will complain of an inconsistent issuer URL due to the issuer actually being https://{tenantName}.b2clogin.com/{tenantId}/v2.0/
.
It appears that Azure B2C doesn't have a general issuer nor a JWK list that contains all of the keys.
Upvotes: 2