Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JwtIssuerReactiveAuthenticationManagerResolver #7887

Merged
merged 1 commit into from
Feb 6, 2020
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
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,77 @@ ReactiveOpaqueTokenIntrospector introspector() {
}
----

[[oauth2resourceserver-multitenancy]]
== Multi-tenancy

A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier.

For example, your resource server may accept bearer tokens from two different authorization servers.
Or, your authorization server may represent a multiplicity of issuers.

In each case, there are two things that need to be done and trade-offs associated with how you choose to do them:

1. Resolve the tenant
2. Propagate the tenant

=== Resolving the Tenant By Claim

One way to differentiate tenants is by the issuer claim. Since the issuer claim accompanies signed JWTs, this can be done with the `JwtIssuerReactiveAuthenticationManagerResolver`, like so:

[source,java]
----
JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerReactiveAuthenticationManagerResolver
("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");

http
.authorizeRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(authenticationManagerResolver)
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, would you mind update this document/example with a bit more details please?

I followed the guide, but the method authenticationManagerResolver() in OAuth2ResourceServerConfigurer complained that it's expecting an AuthenticationManagerResolver, but was receiving JwtIssuerReactiveAuthenticationManagerResolver.

I tried to have my resource server may accept bearer tokens from two different authorization servers, using Spring Boot 2.3.1.RELEASE and spring-security-oauth2-resource-server 5.3.3.RELEASE

Sorry if I have missed something from the guide. Thanks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry to hear you are having trouble, @cmhuynh.

I believe the issue you've described is because you are trying to use a WebFlux class with the Web DSL. AuthenticationManagerResolver matches with JwtIssuerAuthenticationManagerResolver while ReactiveAuthenticationManagerResolver matches with JwtIssuerReactiveAuthenticationManagerResolver.

If you've got a concrete suggestion for how the docs can be improved to help with this issue, please open a separate ticket.

----

This is nice because the issuer endpoints are loaded lazily.
In fact, the corresponding `JwtReactiveAuthenticationManager` is instantiated only when the first request with the corresponding issuer is sent.
This allows for an application startup that is independent from those authorization servers being up and available.

==== Dynamic Tenants

Of course, you may not want to restart the application each time a new tenant is added.
In this case, you can configure the `JwtIssuerReactiveAuthenticationManagerResolver` with a repository of `ReactiveAuthenticationManager` instances, which you can edit at runtime, like so:

[source,java]
----
private Mono<ReactiveAuthenticationManager> addManager(
Map<String, ReactiveAuthenticationManager> authenticationManagers, String issuer) {

return Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(issuer))
.subscribeOn(Schedulers.boundedElastic())
.map(JwtReactiveAuthenticationManager::new)
.doOnNext(authenticationManager -> authenticationManagers.put(issuer, authenticationManager));
}

// ...

JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
new JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get);

http
.authorizeRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(authenticationManagerResolver)
);
----

In this case, you construct `JwtIssuerReactiveAuthenticationManagerResolver` with a strategy for obtaining the `ReactiveAuthenticationManager` given the issuer.
This approach allows us to add and remove elements from the repository (shown as a `Map` in the snippet) at runtime.

NOTE: It would be unsafe to simply take any issuer and construct an `ReactiveAuthenticationManager` from it.
The issuer should be one that the code can verify from a trusted source like a whitelist.

== Bearer Token Propagation

Now that you're in possession of a bearer token, it might be handy to pass that to downstream services.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright 2002-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.security.oauth2.server.resource.authentication;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;

import com.nimbusds.jwt.JWTParser;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;

/**
* An implementation of {@link ReactiveAuthenticationManagerResolver} that resolves a JWT-based
* {@link ReactiveAuthenticationManager} based on the
* <a href="https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> in a
* signed JWT (JWS).
*
* To use, this class must be able to determine whether or not the `iss` claim is trusted. Recall that
* anyone can stand up an authorization server and issue valid tokens to a resource server. The simplest way
* to achieve this is to supply a whitelist of trusted issuers in the constructor.
*
* This class derives the Issuer from the `iss` claim found in the {@link ServerWebExchange}'s
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer Token</a>.
*
* @author Josh Cummings
* @since 5.3
*/
public final class JwtIssuerReactiveAuthenticationManagerResolver
implements ReactiveAuthenticationManagerResolver<ServerWebExchange> {
Copy link

@ghost ghost Feb 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jzheaux it is normal that JwtIssuerReactiveAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver<ServerWebExchange> instead of ReactiveAuthenticationManagerResolver<ServerHttpRequest> ??

/**
		 * Configures the {@link ReactiveAuthenticationManagerResolver}
		 *
		 * @param authenticationManagerResolver the {@link ReactiveAuthenticationManagerResolver}
		 * @return the {@link OAuth2ResourceServerSpec} for additional configuration
		 * @since 5.2
		 */
		public OAuth2ResourceServerSpec authenticationManagerResolver(
				ReactiveAuthenticationManagerResolver<ServerHttpRequest> authenticationManagerResolver) {
			Assert.notNull(authenticationManagerResolver, "authenticationManagerResolver cannot be null");
			this.authenticationManagerResolver = authenticationManagerResolver;
			return this;
		}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, @rmakhlouf, that's correct, thanks for checking. Actually, it's the AuthenticationWebFilter that is mistaken, which you can see in #7872.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jzheaux thanks for this looks good. I'm using spring boot 5.2 so I temporarily back-ported your class into my project (and created a temporary custom ServerBearerTokenAuthenticationConverter) and all is working great in our test environment where we can now support multi tenants :-)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidmelia It will be awesome if you can share you adaptation to spring boot 5.2 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, @davidmelia! Glad to hear it is working for you.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidmelia It will be awesome if you can share you adaptation to spring boot 5.2 :)

Simply copied and slightly amended ServerBearerTokenAuthenticationConverter and JwtIssuerReactiveAuthenticationManagerResolver

package .....;

import com.nimbusds.jwt.JWTParser;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.BearerTokenError;
import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes;
import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

/**
 * Spring Security 5.3 will contain this resolver so please replace then. This class is to support
 * Auth0 multi tenants.
 */
public final class JwtIssuerReactiveAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver<ServerHttpRequest> {
  private static final OAuth2Error DEFAULT_INVALID_TOKEN = invalidToken("Invalid token");

  private final ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver;
  private final Converter<ServerHttpRequest, Mono<String>> issuerConverter = new JwtClaimIssuerConverter();


  public JwtIssuerReactiveAuthenticationManagerResolver(String... trustedIssuers) {
    this(Arrays.asList(trustedIssuers));
  }

  public JwtIssuerReactiveAuthenticationManagerResolver(Collection<String> trustedIssuers) {
    Assert.notEmpty(trustedIssuers, "trustedIssuers cannot be empty");
    this.issuerAuthenticationManagerResolver = new TrustedIssuerJwtAuthenticationManagerResolver(Collections.unmodifiableCollection(trustedIssuers)::contains);
  }


  public JwtIssuerReactiveAuthenticationManagerResolver(ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver) {

    Assert.notNull(issuerAuthenticationManagerResolver, "issuerAuthenticationManagerResolver cannot be null");
    this.issuerAuthenticationManagerResolver = issuerAuthenticationManagerResolver;
  }


  @Override
  public Mono<ReactiveAuthenticationManager> resolve(ServerHttpRequest exchange) {
    return this.issuerConverter.convert(exchange)
        .flatMap(issuer -> this.issuerAuthenticationManagerResolver.resolve(issuer).switchIfEmpty(Mono.error(new OAuth2AuthenticationException(invalidToken("Invalid issuer " + issuer)))));
  }

  private static class JwtClaimIssuerConverter implements Converter<ServerHttpRequest, Mono<String>> {

    private final ServerBearerTokenAuthenticationConverter converter = new ServerBearerTokenAuthenticationConverter();

    @Override
    public Mono<String> convert(@NonNull ServerHttpRequest exchange) {
      return this.converter.convert(exchange).cast(BearerTokenAuthenticationToken.class).flatMap(this::issuer);
    }

    private Mono<String> issuer(BearerTokenAuthenticationToken token) {
      try {
        String issuer = JWTParser.parse(token.getToken()).getJWTClaimsSet().getIssuer();
        return Mono.justOrEmpty(issuer).switchIfEmpty(Mono.error(new OAuth2AuthenticationException(invalidToken("Missing issuer"))));
      } catch (Exception e) {
        return Mono.error(new OAuth2AuthenticationException(invalidToken(e.getMessage())));
      }
    }
  }

  private static class TrustedIssuerJwtAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver<String> {

    private final Map<String, Mono<? extends ReactiveAuthenticationManager>> authenticationManagers = new ConcurrentHashMap<>();
    private final Predicate<String> trustedIssuer;

    TrustedIssuerJwtAuthenticationManagerResolver(Predicate<String> trustedIssuer) {
      this.trustedIssuer = trustedIssuer;
    }

    @Override
    public Mono<ReactiveAuthenticationManager> resolve(String issuer) {
      return Mono.just(issuer).filter(this.trustedIssuer).flatMap(iss -> this.authenticationManagers.computeIfAbsent(iss,
          k -> Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(iss)).subscribeOn(Schedulers.boundedElastic()).map(JwtReactiveAuthenticationManager::new).cache()));
    }
  }

  private static OAuth2Error invalidToken(String message) {
    try {
      return new BearerTokenError(BearerTokenErrorCodes.INVALID_TOKEN, HttpStatus.UNAUTHORIZED, message, "https://tools.ietf.org/html/rfc6750#section-3.1");
    } catch (IllegalArgumentException malformed) {
      // some third-party library error messages are not suitable for RFC 6750's error message charset
      return DEFAULT_INVALID_TOKEN;
    }
  }
}
package ...;

import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.BearerTokenError;
import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;

/**
 * Cut and paste of springs ServerBearerTokenAuthenticationConverter. Not needed when Spring 5.3
 * comes in.
 */
class ServerBearerTokenAuthenticationConverter {
  private static final Pattern authorizationPattern = Pattern.compile("^Bearer (?<token>[a-zA-Z0-9-._~+/]+)=*$", Pattern.CASE_INSENSITIVE);

  private boolean allowUriQueryParameter = false;

  public Mono<Authentication> convert(ServerHttpRequest exchange) {
    return Mono.justOrEmpty(token(exchange)).map(token -> {
      if (token.isEmpty()) {
        BearerTokenError error = invalidTokenError();
        throw new OAuth2AuthenticationException(error);
      }
      return new BearerTokenAuthenticationToken(token);
    });
  }

  private String token(ServerHttpRequest request) {
    String authorizationHeaderToken = resolveFromAuthorizationHeader(request.getHeaders());
    String parameterToken = request.getQueryParams().getFirst("access_token");
    if (authorizationHeaderToken != null) {
      if (parameterToken != null) {
        BearerTokenError error =
            new BearerTokenError(BearerTokenErrorCodes.INVALID_REQUEST, HttpStatus.BAD_REQUEST, "Found multiple bearer tokens in the request", "https://tools.ietf.org/html/rfc6750#section-3.1");
        throw new OAuth2AuthenticationException(error);
      }
      return authorizationHeaderToken;
    } else if (parameterToken != null && isParameterTokenSupportedForRequest(request)) {
      return parameterToken;
    }
    return null;
  }

  public void setAllowUriQueryParameter(boolean allowUriQueryParameter) {
    this.allowUriQueryParameter = allowUriQueryParameter;
  }

  private static String resolveFromAuthorizationHeader(HttpHeaders headers) {
    String authorization = headers.getFirst(HttpHeaders.AUTHORIZATION);
    if (StringUtils.startsWithIgnoreCase(authorization, "bearer")) {
      Matcher matcher = authorizationPattern.matcher(authorization);

      if (!matcher.matches()) {
        BearerTokenError error = invalidTokenError();
        throw new OAuth2AuthenticationException(error);
      }

      return matcher.group("token");
    }
    return null;
  }

  private static BearerTokenError invalidTokenError() {
    return new BearerTokenError(BearerTokenErrorCodes.INVALID_TOKEN, HttpStatus.UNAUTHORIZED, "Bearer token is malformed", "https://tools.ietf.org/html/rfc6750#section-3.1");
  }

  private boolean isParameterTokenSupportedForRequest(ServerHttpRequest request) {
    return this.allowUriQueryParameter && HttpMethod.GET.equals(request.getMethod());
  }
}


private final ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver;
private final Converter<ServerWebExchange, Mono<String>> issuerConverter = new JwtClaimIssuerConverter();

/**
* Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
*
* @param trustedIssuers a whitelist of trusted issuers
*/
public JwtIssuerReactiveAuthenticationManagerResolver(String... trustedIssuers) {
this(Arrays.asList(trustedIssuers));
}

/**
* Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
*
* @param trustedIssuers a whitelist of trusted issuers
*/
public JwtIssuerReactiveAuthenticationManagerResolver(Collection<String> trustedIssuers) {
Assert.notEmpty(trustedIssuers, "trustedIssuers cannot be empty");
this.issuerAuthenticationManagerResolver =
new TrustedIssuerJwtAuthenticationManagerResolver
(Collections.unmodifiableCollection(trustedIssuers)::contains);
}

/**
* Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
*
* Note that the {@link ReactiveAuthenticationManagerResolver} provided in this constructor will need to
* verify that the issuer is trusted. This should be done via a whitelist.
*
* One way to achieve this is with a {@link Map} where the keys are the known issuers:
* <pre>
* Map&lt;String, ReactiveAuthenticationManager&gt; authenticationManagers = new HashMap&lt;&gt;();
* authenticationManagers.put("https://issuerOne.example.org", managerOne);
* authenticationManagers.put("https://issuerTwo.example.org", managerTwo);
* JwtIssuerReactiveAuthenticationManagerResolver resolver = new JwtIssuerReactiveAuthenticationManagerResolver
* (issuer -> Mono.justOrEmpty(authenticationManagers.get(issuer));
* </pre>
*
* The keys in the {@link Map} are the whitelist.
*
* @param issuerAuthenticationManagerResolver a strategy for resolving the {@link ReactiveAuthenticationManager}
* by the issuer
*/
public JwtIssuerReactiveAuthenticationManagerResolver
(ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver) {

Assert.notNull(issuerAuthenticationManagerResolver, "issuerAuthenticationManagerResolver cannot be null");
this.issuerAuthenticationManagerResolver = issuerAuthenticationManagerResolver;
}

/**
* Return an {@link AuthenticationManager} based off of the `iss` claim found in the request's bearer token
*
* @throws OAuth2AuthenticationException if the bearer token is malformed or an {@link ReactiveAuthenticationManager}
* can't be derived from the issuer
*/
@Override
public Mono<ReactiveAuthenticationManager> resolve(ServerWebExchange exchange) {
return this.issuerConverter.convert(exchange)
.flatMap(issuer ->
this.issuerAuthenticationManagerResolver.resolve(issuer).switchIfEmpty(
Mono.error(new InvalidBearerTokenException("Invalid issuer " + issuer)))
);
}

private static class JwtClaimIssuerConverter
implements Converter<ServerWebExchange, Mono<String>> {

private final ServerBearerTokenAuthenticationConverter converter =
new ServerBearerTokenAuthenticationConverter();

@Override
public Mono<String> convert(@NonNull ServerWebExchange exchange) {
return this.converter.convert(exchange)
.cast(BearerTokenAuthenticationToken.class)
.flatMap(this::issuer);
}

private Mono<String> issuer(BearerTokenAuthenticationToken token) {
try {
String issuer = JWTParser.parse(token.getToken()).getJWTClaimsSet().getIssuer();
return Mono.justOrEmpty(issuer).switchIfEmpty(
Mono.error(new InvalidBearerTokenException("Missing issuer")));
} catch (Exception e) {
return Mono.error(new InvalidBearerTokenException(e.getMessage()));
}
}
}

private static class TrustedIssuerJwtAuthenticationManagerResolver
implements ReactiveAuthenticationManagerResolver<String> {

private final Map<String, Mono<? extends ReactiveAuthenticationManager>> authenticationManagers =
new ConcurrentHashMap<>();
private final Predicate<String> trustedIssuer;

TrustedIssuerJwtAuthenticationManagerResolver(Predicate<String> trustedIssuer) {
this.trustedIssuer = trustedIssuer;
}

@Override
public Mono<ReactiveAuthenticationManager> resolve(String issuer) {
return Mono.just(issuer)
.filter(this.trustedIssuer)
.flatMap(iss ->
this.authenticationManagers.computeIfAbsent(iss, k ->
Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(iss))
.subscribeOn(Schedulers.boundedElastic())
.map(JwtReactiveAuthenticationManager::new)
.cache())
);
}
}
}
Loading