Skip to content

Commit de87675

Browse files
committed
Add JwtIssuerAuthenticationManagerResolver
Fixes gh-7724
1 parent 09810b8 commit de87675

File tree

4 files changed

+492
-88
lines changed

4 files changed

+492
-88
lines changed

config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java

+95-2
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,19 @@
2828
import java.time.ZoneId;
2929
import java.util.Base64;
3030
import java.util.Collections;
31+
import java.util.HashMap;
3132
import java.util.Map;
3233
import java.util.stream.Collectors;
3334
import javax.annotation.PreDestroy;
3435

36+
import com.nimbusds.jose.JWSAlgorithm;
37+
import com.nimbusds.jose.JWSHeader;
38+
import com.nimbusds.jose.JWSObject;
39+
import com.nimbusds.jose.Payload;
40+
import com.nimbusds.jose.crypto.RSASSASigner;
41+
import com.nimbusds.jose.jwk.JWKSet;
42+
import com.nimbusds.jose.jwk.RSAKey;
43+
import net.minidev.json.JSONObject;
3544
import okhttp3.mockwebserver.MockResponse;
3645
import okhttp3.mockwebserver.MockWebServer;
3746
import org.hamcrest.core.AllOf;
@@ -82,14 +91,15 @@
8291
import org.springframework.security.oauth2.core.OAuth2Error;
8392
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
8493
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
94+
import org.springframework.security.oauth2.jose.TestKeys;
8595
import org.springframework.security.oauth2.jwt.Jwt;
86-
import org.springframework.security.oauth2.jwt.JwtClaimNames;
8796
import org.springframework.security.oauth2.jwt.JwtDecoder;
8897
import org.springframework.security.oauth2.jwt.JwtException;
8998
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
9099
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
91100
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
92101
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
102+
import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver;
93103
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
94104
import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
95105
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
@@ -127,6 +137,8 @@
127137
import static org.mockito.Mockito.when;
128138
import static org.springframework.security.config.Customizer.withDefaults;
129139
import static org.springframework.security.oauth2.core.TestOAuth2AccessTokens.noScopes;
140+
import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
141+
import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB;
130142
import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withJwkSetUri;
131143
import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withPublicKey;
132144
import static org.springframework.security.oauth2.jwt.TestJwts.jwt;
@@ -149,7 +161,7 @@
149161
public class OAuth2ResourceServerConfigurerTests {
150162
private static final String JWT_TOKEN = "token";
151163
private static final String JWT_SUBJECT = "mock-test-subject";
152-
private static final Map<String, Object> JWT_CLAIMS = Collections.singletonMap(JwtClaimNames.SUB, JWT_SUBJECT);
164+
private static final Map<String, Object> JWT_CLAIMS = Collections.singletonMap(SUB, JWT_SUBJECT);
153165
private static final Jwt JWT = jwt().build();
154166
private static final String JWK_SET_URI = "https://mock.org";
155167
private static final JwtAuthenticationToken JWT_AUTHENTICATION_TOKEN =
@@ -1332,6 +1344,50 @@ public void getAuthenticationManagerWhenConfiguredAuthenticationManagerThenTakes
13321344
verify(http, never()).authenticationProvider(any(AuthenticationProvider.class));
13331345
}
13341346

1347+
// -- authentication manager resolver
1348+
1349+
@Test
1350+
public void getWhenMultipleIssuersThenUsesIssuerClaimToDifferentiate() throws Exception {
1351+
this.spring.register(WebServerConfig.class, MultipleIssuersConfig.class, BasicController.class).autowire();
1352+
1353+
MockWebServer server = this.spring.getContext().getBean(MockWebServer.class);
1354+
String metadata = "{\n"
1355+
+ " \"issuer\": \"%s\", \n"
1356+
+ " \"jwks_uri\": \"%s/.well-known/jwks.json\" \n"
1357+
+ "}";
1358+
String jwkSet = jwkSet();
1359+
String issuerOne = server.url("/issuerOne").toString();
1360+
String issuerTwo = server.url("/issuerTwo").toString();
1361+
String issuerThree = server.url("/issuerThree").toString();
1362+
String jwtOne = jwtFromIssuer(issuerOne);
1363+
String jwtTwo = jwtFromIssuer(issuerTwo);
1364+
String jwtThree = jwtFromIssuer(issuerThree);
1365+
1366+
mockWebServer(String.format(metadata, issuerOne, issuerOne));
1367+
mockWebServer(jwkSet);
1368+
1369+
this.mvc.perform(get("/authenticated")
1370+
.with(bearerToken(jwtOne)))
1371+
.andExpect(status().isOk())
1372+
.andExpect(content().string("test-subject"));
1373+
1374+
mockWebServer(String.format(metadata, issuerTwo, issuerTwo));
1375+
mockWebServer(jwkSet);
1376+
1377+
this.mvc.perform(get("/authenticated")
1378+
.with(bearerToken(jwtTwo)))
1379+
.andExpect(status().isOk())
1380+
.andExpect(content().string("test-subject"));
1381+
1382+
mockWebServer(String.format(metadata, issuerThree, issuerThree));
1383+
mockWebServer(jwkSet);
1384+
1385+
this.mvc.perform(get("/authenticated")
1386+
.with(bearerToken(jwtThree)))
1387+
.andExpect(status().isUnauthorized())
1388+
.andExpect(invalidTokenHeader("Invalid issuer"));
1389+
}
1390+
13351391
// -- Incorrect Configuration
13361392

13371393
@Test
@@ -2070,6 +2126,26 @@ protected void configure(HttpSecurity http) throws Exception {
20702126
}
20712127
}
20722128

2129+
@EnableWebSecurity
2130+
static class MultipleIssuersConfig extends WebSecurityConfigurerAdapter {
2131+
@Autowired
2132+
MockWebServer web;
2133+
2134+
@Override
2135+
protected void configure(HttpSecurity http) throws Exception {
2136+
String issuerOne = this.web.url("/issuerOne").toString();
2137+
String issuerTwo = this.web.url("/issuerTwo").toString();
2138+
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
2139+
new JwtIssuerAuthenticationManagerResolver(issuerOne, issuerTwo);
2140+
2141+
// @formatter:off
2142+
http
2143+
.oauth2ResourceServer()
2144+
.authenticationManagerResolver(authenticationManagerResolver);
2145+
// @formatter:on
2146+
}
2147+
}
2148+
20732149
@EnableWebSecurity
20742150
static class AuthenticationManagerResolverPlusOtherConfig extends WebSecurityConfigurerAdapter {
20752151
@Override
@@ -2257,6 +2333,23 @@ private static ResultMatcher insufficientScopeHeader() {
22572333
", error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"");
22582334
}
22592335

2336+
private String jwkSet() {
2337+
return new JWKSet(new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY)
2338+
.keyID("1").build()).toString();
2339+
}
2340+
2341+
private String jwtFromIssuer(String issuer) throws Exception {
2342+
Map<String, Object> claims = new HashMap<>();
2343+
claims.put(ISS, issuer);
2344+
claims.put(SUB, "test-subject");
2345+
claims.put("scope", "message:read");
2346+
JWSObject jws = new JWSObject(
2347+
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("1").build(),
2348+
new Payload(new JSONObject(claims)));
2349+
jws.sign(new RSASSASigner(TestKeys.DEFAULT_PRIVATE_KEY));
2350+
return jws.serialize();
2351+
}
2352+
22602353
private void mockWebServer(String response) {
22612354
this.web.enqueue(new MockResponse()
22622355
.setResponseCode(200)

docs/manual/src/docs/asciidoc/_includes/servlet/oauth2/oauth2-resourceserver.adoc

+31-86
Original file line numberDiff line numberDiff line change
@@ -1243,123 +1243,68 @@ In each case, there are two things that need to be done and trade-offs associate
12431243
1. Resolve the tenant
12441244
2. Propagate the tenant
12451245

1246-
==== Resolving the Tenant By Request Material
1246+
==== Resolving the Tenant By Claim
12471247

1248-
Resolving the tenant by request material can be done my implementing an `AuthenticationManagerResolver`, which determines the `AuthenticationManager` at runtime, like so:
1248+
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:
12491249

12501250
[source,java]
12511251
----
1252-
@Component
1253-
public class TenantAuthenticationManagerResolver
1254-
implements AuthenticationManagerResolver<HttpServletRequest> {
1255-
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
1256-
private final TenantRepository tenants; <1>
1257-
1258-
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
1259-
1260-
public TenantAuthenticationManagerResolver(TenantRepository tenants) {
1261-
this.tenants = tenants;
1262-
}
1263-
1264-
@Override
1265-
public AuthenticationManager resolve(HttpServletRequest request) {
1266-
return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);
1267-
}
1268-
1269-
private String toTenant(HttpServletRequest request) {
1270-
String[] pathParts = request.getRequestURI().split("/");
1271-
return pathParts.length > 0 ? pathParts[1] : null;
1272-
}
1252+
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver
1253+
("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");
12731254
1274-
private AuthenticationManager fromTenant(String tenant) {
1275-
return Optional.ofNullable(this.tenants.get(tenant)) <3>
1276-
.map(JwtDecoders::fromIssuerLocation) <4>
1277-
.map(JwtAuthenticationProvider::new)
1278-
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
1279-
}
1280-
}
1281-
----
1282-
<1> A hypothetical source for tenant information
1283-
<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
1284-
<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
1285-
<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup
1286-
1287-
And then specify this `AuthenticationManagerResolver` in the DSL:
1288-
1289-
[source,java]
1290-
----
12911255
http
12921256
.authorizeRequests(authorizeRequests ->
12931257
authorizeRequests
12941258
.anyRequest().authenticated()
12951259
)
12961260
.oauth2ResourceServer(oauth2ResourceServer ->
12971261
oauth2ResourceServer
1298-
.authenticationManagerResolver(this.tenantAuthenticationManagerResolver)
1262+
.authenticationManagerResolver(authenticationManagerResolver)
12991263
);
13001264
----
13011265

1302-
==== Resolving the Tenant By Claim
1266+
This is nice because the issuer endpoints are loaded lazily.
1267+
In fact, the corresponding `JwtAuthenticationProvider` is instantiated only when the first request with the corresponding issuer is sent.
1268+
This allows for an application startup that is independent from those authorization servers being up and available.
1269+
1270+
===== Dynamic Tenants
13031271

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

13071275
[source,java]
13081276
----
1309-
@Component
1310-
public class TenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
1311-
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
1312-
private final TenantRepository tenants; <1>
1313-
1314-
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
1277+
private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
1278+
JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
1279+
(JwtDecoders.fromIssuerLocation(issuer));
1280+
authenticationManagers.put(issuer, authenticationProvider::authenticate);
1281+
}
13151282
1316-
public TenantAuthenticationManagerResolver(TenantRepository tenants) {
1317-
this.tenants = tenants;
1318-
}
1283+
// ...
13191284
1320-
@Override
1321-
public AuthenticationManager resolve(HttpServletRequest request) {
1322-
return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant); <3>
1323-
}
1285+
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
1286+
new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);
13241287
1325-
private String toTenant(HttpServletRequest request) {
1326-
try {
1327-
String token = this.resolver.resolve(request);
1328-
return (String) JWTParser.parse(token).getJWTClaimsSet().getIssuer();
1329-
} catch (Exception e) {
1330-
throw new IllegalArgumentException(e);
1331-
}
1332-
}
1333-
1334-
private AuthenticationManager fromTenant(String tenant) {
1335-
return Optional.ofNullable(this.tenants.get(tenant)) <3>
1336-
.map(JwtDecoders::fromIssuerLocation) <4>
1337-
.map(JwtAuthenticationProvider::new)
1338-
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
1339-
}
1340-
}
1341-
----
1342-
<1> A hypothetical source for tenant information
1343-
<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
1344-
<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
1345-
<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup
1346-
1347-
[source,java]
1348-
----
13491288
http
13501289
.authorizeRequests(authorizeRequests ->
13511290
authorizeRequests
13521291
.anyRequest().authenticated()
13531292
)
13541293
.oauth2ResourceServer(oauth2ResourceServer ->
13551294
oauth2ResourceServer
1356-
.authenticationManagerResolver(this.tenantAuthenticationManagerResolver)
1295+
.authenticationManagerResolver(authenticationManagerResolver)
13571296
);
13581297
----
13591298

1360-
==== Parsing the Claim Only Once
1299+
In this case, you construct `JwtIssuerAuthenticationManagerResolver` with a strategy for obtaining the `AuthenticationManager` given the issuer.
1300+
This approach allows us to add and remove elements from the repository (shown as a `Map` in the snippet) at runtime.
1301+
1302+
NOTE: It would be unsafe to simply take any issuer and construct an `AuthenticationManager` from it.
1303+
The issuer should be one that the code can verify from a trusted source like a whitelist.
1304+
1305+
===== Parsing the Claim Only Once
13611306

1362-
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`.
1307+
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.
13631308

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

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

14801425
We've finished talking about resolving the tenant.
14811426

1482-
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.
1483-
For example, if you are resolving it by subdomain, you'll need to address the downstream resource server using the same subdomain.
1427+
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.
1428+
For example, if you are resolving it by subdomain, you may need to address the downstream resource server using the same subdomain.
14841429

14851430
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>>.
14861431

0 commit comments

Comments
 (0)