Skip to content

Commit 63647e9

Browse files
committed
Add Resource Server Multi-tenancy Docs
Fixes: gh-7532
1 parent bd4f205 commit 63647e9

File tree

1 file changed

+286
-2
lines changed

1 file changed

+286
-2
lines changed

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

+286-2
Original file line numberDiff line numberDiff line change
@@ -1148,8 +1148,292 @@ OpaqueTokenIntrospector introspector() {
11481148
}
11491149
----
11501150

1151-
Thus far we have only taken a look at the most basic authentication configuration.
1152-
Let's take a look at a few slightly more advanced options for configuring authentication.
1151+
[[oauth2reourceserver-opaqueandjwt]]
1152+
=== Supporting both JWT and Opaque Token
1153+
1154+
In some cases, you may have a need to access both kinds of tokens.
1155+
For example, you may support more than one tenant where one tenant issues JWTs and the other issues opaque tokens.
1156+
1157+
If this decision must be made at request-time, then you can use an `AuthenticationManagerResolver` to achieve it, like so:
1158+
1159+
[source,java]
1160+
----
1161+
@Bean
1162+
AuthenticationManagerResolver<HttpServletRequest> tokenAuthenticationManagerResolver() {
1163+
BearerTokenResolver bearerToken = new DefaultBearerTokenResolver();
1164+
JwtAuthenticationProvider jwt = jwt();
1165+
OpaqueTokenAuthenticationProvider opaqueToken = opaqueToken();
1166+
1167+
return request -> {
1168+
String token = bearerToken.resolve(request);
1169+
if (isAJwt(token)) {
1170+
return jwt::authenticate;
1171+
} else {
1172+
return opaqueToken::authenticate;
1173+
}
1174+
}
1175+
}
1176+
----
1177+
1178+
And then specify this `AuthenticationManagerResolver` in the DSL:
1179+
1180+
[source,java]
1181+
----
1182+
http
1183+
.authorizeRequests()
1184+
.anyRequest().authenticated()
1185+
.and()
1186+
.oauth2ResourceServer()
1187+
.authenticationManagerResolver(this.tokenAuthenticationManagerResolver);
1188+
----
1189+
1190+
[[oauth2resourceserver-multitenancy]]
1191+
=== Multi-tenancy
1192+
1193+
A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier.
1194+
1195+
For example, your resource server may accept bearer tokens from two different authorization servers.
1196+
Or, your authorization server may represent a multiplicity of issuers.
1197+
1198+
In each case, there are two things that need to be done and trade-offs associated with how you choose to do them:
1199+
1200+
1. Resolve the tenant
1201+
2. Propagate the tenant
1202+
1203+
==== Resolving the Tenant By Request Material
1204+
1205+
Resolving the tenant by request material can be done my implementing an `AuthenticationManagerResolver`, which determines the `AuthenticationManager` at runtime, like so:
1206+
1207+
[source,java]
1208+
----
1209+
@Component
1210+
public class TenantAuthenticationManagerResolver
1211+
implements AuthenticationManagerResolver<HttpServletRequest> {
1212+
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
1213+
private final TenantRepository tenants; <1>
1214+
1215+
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
1216+
1217+
public TenantAuthenticationManagerResolver(TenantRepository tenants) {
1218+
this.tenants = tenants;
1219+
}
1220+
1221+
@Override
1222+
public AuthenticationManager resolve(HttpServletRequest request) {
1223+
return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);
1224+
}
1225+
1226+
private String toTenant(HttpServletRequest request) {
1227+
String[] pathParts = request.getRequestURI().split("/");
1228+
return pathParts.length > 0 ? pathParts[1] : null;
1229+
}
1230+
1231+
private AuthenticationManager fromTenant(String tenant) {
1232+
return Optional.ofNullable(this.tenants.get(tenant)) <3>
1233+
.map(JwtDecoders::fromIssuerLocation) <4>
1234+
.map(JwtAuthenticationProvider::new)
1235+
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
1236+
}
1237+
}
1238+
----
1239+
<1> A hypothetical source for tenant information
1240+
<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
1241+
<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
1242+
<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup
1243+
1244+
And then specify this `AuthenticationManagerResolver` in the DSL:
1245+
1246+
[source,java]
1247+
----
1248+
http
1249+
.authorizeRequests()
1250+
.anyRequest().authenticated()
1251+
.and()
1252+
.oauth2ResourceServer()
1253+
.authenticationManagerResolver(this.tenantAuthenticationManagerResolver);
1254+
----
1255+
1256+
==== Resolving the Tenant By Claim
1257+
1258+
Resolving the tenant by claim is similar to doing so by request material.
1259+
The only real difference is the `toTenant` method implementation:
1260+
1261+
[source,java]
1262+
----
1263+
@Component
1264+
public class TenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
1265+
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
1266+
private final TenantRepository tenants; <1>
1267+
1268+
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>(); <2>
1269+
1270+
public TenantAuthenticationManagerResolver(TenantRepository tenants) {
1271+
this.tenants = tenants;
1272+
}
1273+
1274+
@Override
1275+
public AuthenticationManager resolve(HttpServletRequest request) {
1276+
return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant); <3>
1277+
}
1278+
1279+
private String toTenant(HttpServletRequest request) {
1280+
try {
1281+
String token = this.resolver.resolve(request);
1282+
return (String) JWTParser.parse(token).getJWTClaimsSet().getIssuer();
1283+
} catch (Exception e) {
1284+
throw new IllegalArgumentException(e);
1285+
}
1286+
}
1287+
1288+
private AuthenticationManager fromTenant(String tenant) {
1289+
return Optional.ofNullable(this.tenants.get(tenant)) <3>
1290+
.map(JwtDecoders::fromIssuerLocation) <4>
1291+
.map(JwtAuthenticationProvider::new)
1292+
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
1293+
}
1294+
}
1295+
----
1296+
<1> A hypothetical source for tenant information
1297+
<2> A cache for `AuthenticationManager`s, keyed by tenant identifier
1298+
<3> Looking up the tenant is more secure than simply computing the issuer location on the fly - the lookup acts as a tenant whitelist
1299+
<4> Create a `JwtDecoder` via the discovery endpoint - the lazy lookup here means that you don't need to configure all tenants at startup
1300+
1301+
[source,java]
1302+
----
1303+
http
1304+
.authorizeRequests()
1305+
.anyRequest().authenticated()
1306+
.and()
1307+
.oauth2ResourceServer()
1308+
.authenticationManagerResolver(this.tenantAuthenticationManagerResolver);
1309+
----
1310+
1311+
==== Parsing the Claim Only Once
1312+
1313+
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`.
1314+
1315+
This extra parsing can be alleviated by configuring the `JwtDecoder` directly with a `JWTClaimSetAwareJWSKeySelector` from Nimbus:
1316+
1317+
[source,java]
1318+
----
1319+
@Component
1320+
public class TenantJWSKeySelector
1321+
implements JWTClaimSetAwareJWSKeySelector<SecurityContext> {
1322+
1323+
private final TenantRepository tenants; <1>
1324+
private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>(); <2>
1325+
1326+
public TenantJWSKeySelector(TenantRepository tenants) {
1327+
this.tenants = tenants;
1328+
}
1329+
1330+
@Override
1331+
public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext)
1332+
throws KeySourceException {
1333+
return this.selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant)
1334+
.selectJWSKeys(jwsHeader, securityContext);
1335+
}
1336+
1337+
private String toTenant(JWTClaimsSet claimSet) {
1338+
return (String) claimSet.getClaim("iss");
1339+
}
1340+
1341+
private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
1342+
return Optional.ofNullable(this.tenantRepository.findById(tenant)) <3>
1343+
.map(t -> t.getAttrbute("jwks_uri"))
1344+
.map(this::fromUri)
1345+
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
1346+
}
1347+
1348+
private JWSKeySelector<SecurityContext> fromUri(String uri) {
1349+
try {
1350+
return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri)); <4>
1351+
} catch (Exception e) {
1352+
throw new IllegalArgumentException(e);
1353+
}
1354+
}
1355+
}
1356+
----
1357+
<1> A hypothetical source for tenant information
1358+
<2> A cache for `JWKKeySelector`s, keyed by tenant identifier
1359+
<3> Looking up the tenant is more secure than simply calculating the JWK Set endpoint on the fly - the lookup acts as a tenant whitelist
1360+
<4> Create a `JWSKeySelector` via the types of keys that come back from the JWK Set endpoint - the lazy lookup here means that you don't need to configure all tenants at startup
1361+
1362+
The above key selector is a composition of many key selectors.
1363+
It chooses which key selector to use based on the `iss` claim in the JWT.
1364+
1365+
NOTE: To use this approach, make sure that the authorization server is configured to include the claim set as part of the token's signature.
1366+
Without this, you have no guarantee that the issuer hasn't been altered by a bad actor.
1367+
1368+
Next, we can construct a `JWTProcessor`:
1369+
1370+
[source,java]
1371+
----
1372+
@Bean
1373+
JWTProcessor jwtProcessor(JWTClaimSetJWSKeySelector keySelector) {
1374+
ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
1375+
new DefaultJWTProcessor();
1376+
jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector);
1377+
return jwtProcessor;
1378+
}
1379+
----
1380+
1381+
As you are already seeing, the trade-off for moving tenant-awareness down to this level is more configuration.
1382+
We have just a bit more.
1383+
1384+
Next, we still want to make sure you are validating the issuer.
1385+
But, since the issuer may be different per JWT, then you'll need a tenant-aware validator, too:
1386+
1387+
[source,java]
1388+
----
1389+
@Component
1390+
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
1391+
private final TenantRepository tenants;
1392+
private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();
1393+
1394+
public TenantJwtIssuerValidator(TenantRepository tenants) {
1395+
this.tenants = tenants;
1396+
}
1397+
1398+
@Override
1399+
public OAuth2TokenValidatorResult validate(Jwt token) {
1400+
return this.validators.computeIfAbsent(toTenant(token), this::fromTenant)
1401+
.validate(token);
1402+
}
1403+
1404+
private String toTenant(Jwt jwt) {
1405+
return jwt.getIssuer();
1406+
}
1407+
1408+
private JwtIssuerValidator fromTenant(String tenant) {
1409+
return Optional.ofNullable(this.tenants.findById(tenant))
1410+
.map(t -> t.getAttribute("issuer"))
1411+
.map(JwtIssuerValidator::new)
1412+
.orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
1413+
}
1414+
}
1415+
----
1416+
1417+
Now that we have a tenant-aware processor and a tenant-aware validator, we can proceed with creating our `JwtDecoder`:
1418+
1419+
[source,java]
1420+
----
1421+
@Bean
1422+
JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
1423+
NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor);
1424+
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>
1425+
(JwtValidators.createDefault(), this.jwtValidator);
1426+
decoder.setJwtValidator(validator);
1427+
return decoder;
1428+
}
1429+
----
1430+
1431+
We've finished talking about resolving the tenant.
1432+
1433+
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.
1434+
For example, if you are resolving it by subdomain, you'll need to address the downstream resource server using the same subdomain.
1435+
1436+
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>>.
11531437

11541438
[[oauth2resourceserver-bearertoken-resolver]]
11551439
=== Bearer Token Resolution

0 commit comments

Comments
 (0)