Joseph Woolf
Joseph Woolf

Reputation: 550

WebFlux: Can't authenticate JWT token from Azure B2C

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:

  1. What exactly is going on with the issuer URL?
  2. How can I allow Spring Security 5 reactive to work with Azure B2C?
  3. I notice that the JWT Decoder is called only once at startup. It doesn't get called when an endpoint is being called. Why is this the case?

Upvotes: 2

Views: 2249

Answers (2)

Kim Gentes
Kim Gentes

Reputation: 1628

Basic Implementation

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

With Proxy Settings Implementation

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

Joseph Woolf
Joseph Woolf

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

Related Questions