Skip to content

Commit

Permalink
Add JwtIssuerAuthenticationManagerResolver
Browse files Browse the repository at this point in the history
Fixes gh-7724
  • Loading branch information
jzheaux committed Jan 8, 2020
1 parent 09810b8 commit de87675
Show file tree
Hide file tree
Showing 4 changed files with 492 additions and 88 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@
import java.time.ZoneId;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import javax.annotation.PreDestroy;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import net.minidev.json.JSONObject;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.hamcrest.core.AllOf;
Expand Down Expand Up @@ -82,14 +91,15 @@
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.jose.TestKeys;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
Expand Down Expand Up @@ -127,6 +137,8 @@
import static org.mockito.Mockito.when;
import static org.springframework.security.config.Customizer.withDefaults;
import static org.springframework.security.oauth2.core.TestOAuth2AccessTokens.noScopes;
import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB;
import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withJwkSetUri;
import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withPublicKey;
import static org.springframework.security.oauth2.jwt.TestJwts.jwt;
Expand All @@ -149,7 +161,7 @@
public class OAuth2ResourceServerConfigurerTests {
private static final String JWT_TOKEN = "token";
private static final String JWT_SUBJECT = "mock-test-subject";
private static final Map<String, Object> JWT_CLAIMS = Collections.singletonMap(JwtClaimNames.SUB, JWT_SUBJECT);
private static final Map<String, Object> JWT_CLAIMS = Collections.singletonMap(SUB, JWT_SUBJECT);
private static final Jwt JWT = jwt().build();
private static final String JWK_SET_URI = "https://mock.org";
private static final JwtAuthenticationToken JWT_AUTHENTICATION_TOKEN =
Expand Down Expand Up @@ -1332,6 +1344,50 @@ public void getAuthenticationManagerWhenConfiguredAuthenticationManagerThenTakes
verify(http, never()).authenticationProvider(any(AuthenticationProvider.class));
}

// -- authentication manager resolver

@Test
public void getWhenMultipleIssuersThenUsesIssuerClaimToDifferentiate() throws Exception {
this.spring.register(WebServerConfig.class, MultipleIssuersConfig.class, BasicController.class).autowire();

MockWebServer server = this.spring.getContext().getBean(MockWebServer.class);
String metadata = "{\n"
+ " \"issuer\": \"%s\", \n"
+ " \"jwks_uri\": \"%s/.well-known/jwks.json\" \n"
+ "}";
String jwkSet = jwkSet();
String issuerOne = server.url("/issuerOne").toString();
String issuerTwo = server.url("/issuerTwo").toString();
String issuerThree = server.url("/issuerThree").toString();
String jwtOne = jwtFromIssuer(issuerOne);
String jwtTwo = jwtFromIssuer(issuerTwo);
String jwtThree = jwtFromIssuer(issuerThree);

mockWebServer(String.format(metadata, issuerOne, issuerOne));
mockWebServer(jwkSet);

this.mvc.perform(get("/authenticated")
.with(bearerToken(jwtOne)))
.andExpect(status().isOk())
.andExpect(content().string("test-subject"));

mockWebServer(String.format(metadata, issuerTwo, issuerTwo));
mockWebServer(jwkSet);

this.mvc.perform(get("/authenticated")
.with(bearerToken(jwtTwo)))
.andExpect(status().isOk())
.andExpect(content().string("test-subject"));

mockWebServer(String.format(metadata, issuerThree, issuerThree));
mockWebServer(jwkSet);

this.mvc.perform(get("/authenticated")
.with(bearerToken(jwtThree)))
.andExpect(status().isUnauthorized())
.andExpect(invalidTokenHeader("Invalid issuer"));
}

// -- Incorrect Configuration

@Test
Expand Down Expand Up @@ -2070,6 +2126,26 @@ protected void configure(HttpSecurity http) throws Exception {
}
}

@EnableWebSecurity
static class MultipleIssuersConfig extends WebSecurityConfigurerAdapter {
@Autowired
MockWebServer web;

@Override
protected void configure(HttpSecurity http) throws Exception {
String issuerOne = this.web.url("/issuerOne").toString();
String issuerTwo = this.web.url("/issuerTwo").toString();
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
new JwtIssuerAuthenticationManagerResolver(issuerOne, issuerTwo);

// @formatter:off
http
.oauth2ResourceServer()
.authenticationManagerResolver(authenticationManagerResolver);
// @formatter:on
}
}

@EnableWebSecurity
static class AuthenticationManagerResolverPlusOtherConfig extends WebSecurityConfigurerAdapter {
@Override
Expand Down Expand Up @@ -2257,6 +2333,23 @@ private static ResultMatcher insufficientScopeHeader() {
", error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"");
}

private String jwkSet() {
return new JWKSet(new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY)
.keyID("1").build()).toString();
}

private String jwtFromIssuer(String issuer) throws Exception {
Map<String, Object> claims = new HashMap<>();
claims.put(ISS, issuer);
claims.put(SUB, "test-subject");
claims.put("scope", "message:read");
JWSObject jws = new JWSObject(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("1").build(),
new Payload(new JSONObject(claims)));
jws.sign(new RSASSASigner(TestKeys.DEFAULT_PRIVATE_KEY));
return jws.serialize();
}

private void mockWebServer(String response) {
this.web.enqueue(new MockResponse()
.setResponseCode(200)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1243,123 +1243,68 @@ In each case, there are two things that need to be done and trade-offs associate
1. Resolve the tenant
2. Propagate the tenant

==== Resolving the Tenant By Request Material
==== Resolving the Tenant By Claim

Resolving the tenant by request material can be done my implementing an `AuthenticationManagerResolver`, which determines the `AuthenticationManager` at runtime, like so:
One way to differentiate tenants is by the issuer claim. Since the issuer claim accompanies signed JWTs, this can be done with the `JwtIssuerAuthenticationManagerResolver`, like so:

[source,java]
----
@Component
public class TenantAuthenticationManagerResolver
implements AuthenticationManagerResolver<HttpServletRequest> {
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
private final TenantRepository tenants; <1>
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
public TenantAuthenticationManagerResolver(TenantRepository tenants) {
this.tenants = tenants;
}
@Override
public AuthenticationManager resolve(HttpServletRequest request) {
return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);
}
private String toTenant(HttpServletRequest request) {
String[] pathParts = request.getRequestURI().split("/");
return pathParts.length > 0 ? pathParts[1] : null;
}
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver
("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");
private AuthenticationManager fromTenant(String tenant) {
return Optional.ofNullable(this.tenants.get(tenant)) <3>
.map(JwtDecoders::fromIssuerLocation) <4>
.map(JwtAuthenticationProvider::new)
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
}
}
----
<1> A hypothetical source for tenant information
<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup

And then specify this `AuthenticationManagerResolver` in the DSL:

[source,java]
----
http
.authorizeRequests(authorizeRequests ->
authorizeRequests
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer
.authenticationManagerResolver(this.tenantAuthenticationManagerResolver)
.authenticationManagerResolver(authenticationManagerResolver)
);
----

==== Resolving the Tenant By Claim
This is nice because the issuer endpoints are loaded lazily.
In fact, the corresponding `JwtAuthenticationProvider` 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

Resolving the tenant by claim is similar to doing so by request material.
The only real difference is the `toTenant` method implementation:
Of course, you may not want to restart the application each time a new tenant is added.
In this case, you can configure the `JwtIssuerAuthenticationManagerResolver` with a repository of `AuthenticationManager` instances, which you can edit at runtime, like so:

[source,java]
----
@Component
public class TenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
private final TenantRepository tenants; <1>
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
(JwtDecoders.fromIssuerLocation(issuer));
authenticationManagers.put(issuer, authenticationProvider::authenticate);
}
public TenantAuthenticationManagerResolver(TenantRepository tenants) {
this.tenants = tenants;
}
// ...
@Override
public AuthenticationManager resolve(HttpServletRequest request) {
return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant); <3>
}
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);
private String toTenant(HttpServletRequest request) {
try {
String token = this.resolver.resolve(request);
return (String) JWTParser.parse(token).getJWTClaimsSet().getIssuer();
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
private AuthenticationManager fromTenant(String tenant) {
return Optional.ofNullable(this.tenants.get(tenant)) <3>
.map(JwtDecoders::fromIssuerLocation) <4>
.map(JwtAuthenticationProvider::new)
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
}
}
----
<1> A hypothetical source for tenant information
<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup

[source,java]
----
http
.authorizeRequests(authorizeRequests ->
authorizeRequests
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer
.authenticationManagerResolver(this.tenantAuthenticationManagerResolver)
.authenticationManagerResolver(authenticationManagerResolver)
);
----

==== Parsing the Claim Only Once
In this case, you construct `JwtIssuerAuthenticationManagerResolver` with a strategy for obtaining the `AuthenticationManager` 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 `AuthenticationManager` from it.
The issuer should be one that the code can verify from a trusted source like a whitelist.

===== Parsing the Claim Only Once

You may have observed that this strategy, while simple, comes with the trade-off that the JWT is parsed once by the `AuthenticationManagerResolver` and then again by the `JwtDecoder`.
You may have observed that this strategy, while simple, comes with the trade-off that the JWT is parsed once by the `AuthenticationManagerResolver` and then again by the `JwtDecoder` later on in the request.

This extra parsing can be alleviated by configuring the `JwtDecoder` directly with a `JWTClaimSetAwareJWSKeySelector` from Nimbus:

Expand Down Expand Up @@ -1479,8 +1424,8 @@ JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtVa

We've finished talking about resolving the tenant.

If you've chosen to resolve the tenant by request material, then you'll need to make sure you address your downstream resource servers in the same way.
For example, if you are resolving it by subdomain, you'll need to address the downstream resource server using the same subdomain.
If you've chosen to resolve the tenant by something other than a JWT claim, then you'll need to make sure you address your downstream resource servers in the same way.
For example, if you are resolving it by subdomain, you may need to address the downstream resource server using the same subdomain.

However, if you resolve it by a claim in the bearer token, read on to learn about <<oauth2resourceserver-bearertoken-resolver,Spring Security's support for bearer token propagation>>.

Expand Down
Loading

0 comments on commit de87675

Please sign in to comment.