Skip to content

Commit 13c6e07

Browse files
committed
Add JwtIssuerReactiveAuthenticationManagerResolver
Fixes gh-7857
1 parent 04f3fe8 commit 13c6e07

File tree

3 files changed

+433
-0
lines changed

3 files changed

+433
-0
lines changed

docs/manual/src/docs/asciidoc/_includes/reactive/oauth2/resource-server.adoc

+71
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,77 @@ ReactiveOpaqueTokenIntrospector introspector() {
10051005
}
10061006
----
10071007

1008+
[[oauth2resourceserver-multitenancy]]
1009+
== Multi-tenancy
1010+
1011+
A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier.
1012+
1013+
For example, your resource server may accept bearer tokens from two different authorization servers.
1014+
Or, your authorization server may represent a multiplicity of issuers.
1015+
1016+
In each case, there are two things that need to be done and trade-offs associated with how you choose to do them:
1017+
1018+
1. Resolve the tenant
1019+
2. Propagate the tenant
1020+
1021+
=== Resolving the Tenant By Claim
1022+
1023+
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:
1024+
1025+
[source,java]
1026+
----
1027+
JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerReactiveAuthenticationManagerResolver
1028+
("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");
1029+
1030+
http
1031+
.authorizeRequests(authorize -> authorize
1032+
.anyRequest().authenticated()
1033+
)
1034+
.oauth2ResourceServer(oauth2 -> oauth2
1035+
.authenticationManagerResolver(authenticationManagerResolver)
1036+
);
1037+
----
1038+
1039+
This is nice because the issuer endpoints are loaded lazily.
1040+
In fact, the corresponding `JwtReactiveAuthenticationManager` is instantiated only when the first request with the corresponding issuer is sent.
1041+
This allows for an application startup that is independent from those authorization servers being up and available.
1042+
1043+
==== Dynamic Tenants
1044+
1045+
Of course, you may not want to restart the application each time a new tenant is added.
1046+
In this case, you can configure the `JwtIssuerReactiveAuthenticationManagerResolver` with a repository of `ReactiveAuthenticationManager` instances, which you can edit at runtime, like so:
1047+
1048+
[source,java]
1049+
----
1050+
private Mono<ReactiveAuthenticationManager> addManager(
1051+
Map<String, ReactiveAuthenticationManager> authenticationManagers, String issuer) {
1052+
1053+
return Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(issuer))
1054+
.subscribeOn(Schedulers.boundedElastic())
1055+
.map(JwtReactiveAuthenticationManager::new)
1056+
.doOnNext(authenticationManager -> authenticationManagers.put(issuer, authenticationManager));
1057+
}
1058+
1059+
// ...
1060+
1061+
JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver =
1062+
new JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get);
1063+
1064+
http
1065+
.authorizeRequests(authorize -> authorize
1066+
.anyRequest().authenticated()
1067+
)
1068+
.oauth2ResourceServer(oauth2 -> oauth2
1069+
.authenticationManagerResolver(authenticationManagerResolver)
1070+
);
1071+
----
1072+
1073+
In this case, you construct `JwtIssuerReactiveAuthenticationManagerResolver` with a strategy for obtaining the `ReactiveAuthenticationManager` given the issuer.
1074+
This approach allows us to add and remove elements from the repository (shown as a `Map` in the snippet) at runtime.
1075+
1076+
NOTE: It would be unsafe to simply take any issuer and construct an `ReactiveAuthenticationManager` from it.
1077+
The issuer should be one that the code can verify from a trusted source like a whitelist.
1078+
10081079
== Bearer Token Propagation
10091080

10101081
Now that you're in possession of a bearer token, it might be handy to pass that to downstream services.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright 2002-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.oauth2.server.resource.authentication;
18+
19+
import java.util.Arrays;
20+
import java.util.Collection;
21+
import java.util.Collections;
22+
import java.util.Map;
23+
import java.util.concurrent.ConcurrentHashMap;
24+
import java.util.function.Predicate;
25+
26+
import com.nimbusds.jwt.JWTParser;
27+
import reactor.core.publisher.Mono;
28+
import reactor.core.scheduler.Schedulers;
29+
30+
import org.springframework.core.convert.converter.Converter;
31+
import org.springframework.lang.NonNull;
32+
import org.springframework.security.authentication.AuthenticationManager;
33+
import org.springframework.security.authentication.ReactiveAuthenticationManager;
34+
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
35+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
36+
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders;
37+
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
38+
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
39+
import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter;
40+
import org.springframework.util.Assert;
41+
import org.springframework.web.server.ServerWebExchange;
42+
43+
/**
44+
* An implementation of {@link ReactiveAuthenticationManagerResolver} that resolves a JWT-based
45+
* {@link ReactiveAuthenticationManager} based on the
46+
* <a href="https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> in a
47+
* signed JWT (JWS).
48+
*
49+
* To use, this class must be able to determine whether or not the `iss` claim is trusted. Recall that
50+
* anyone can stand up an authorization server and issue valid tokens to a resource server. The simplest way
51+
* to achieve this is to supply a whitelist of trusted issuers in the constructor.
52+
*
53+
* This class derives the Issuer from the `iss` claim found in the {@link ServerWebExchange}'s
54+
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer Token</a>.
55+
*
56+
* @author Josh Cummings
57+
* @since 5.3
58+
*/
59+
public final class JwtIssuerReactiveAuthenticationManagerResolver
60+
implements ReactiveAuthenticationManagerResolver<ServerWebExchange> {
61+
62+
private final ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver;
63+
private final Converter<ServerWebExchange, Mono<String>> issuerConverter = new JwtClaimIssuerConverter();
64+
65+
/**
66+
* Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
67+
*
68+
* @param trustedIssuers a whitelist of trusted issuers
69+
*/
70+
public JwtIssuerReactiveAuthenticationManagerResolver(String... trustedIssuers) {
71+
this(Arrays.asList(trustedIssuers));
72+
}
73+
74+
/**
75+
* Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
76+
*
77+
* @param trustedIssuers a whitelist of trusted issuers
78+
*/
79+
public JwtIssuerReactiveAuthenticationManagerResolver(Collection<String> trustedIssuers) {
80+
Assert.notEmpty(trustedIssuers, "trustedIssuers cannot be empty");
81+
this.issuerAuthenticationManagerResolver =
82+
new TrustedIssuerJwtAuthenticationManagerResolver
83+
(Collections.unmodifiableCollection(trustedIssuers)::contains);
84+
}
85+
86+
/**
87+
* Construct a {@link JwtIssuerReactiveAuthenticationManagerResolver} using the provided parameters
88+
*
89+
* Note that the {@link ReactiveAuthenticationManagerResolver} provided in this constructor will need to
90+
* verify that the issuer is trusted. This should be done via a whitelist.
91+
*
92+
* One way to achieve this is with a {@link Map} where the keys are the known issuers:
93+
* <pre>
94+
* Map&lt;String, ReactiveAuthenticationManager&gt; authenticationManagers = new HashMap&lt;&gt;();
95+
* authenticationManagers.put("https://issuerOne.example.org", managerOne);
96+
* authenticationManagers.put("https://issuerTwo.example.org", managerTwo);
97+
* JwtIssuerReactiveAuthenticationManagerResolver resolver = new JwtIssuerReactiveAuthenticationManagerResolver
98+
* (issuer -> Mono.justOrEmpty(authenticationManagers.get(issuer));
99+
* </pre>
100+
*
101+
* The keys in the {@link Map} are the whitelist.
102+
*
103+
* @param issuerAuthenticationManagerResolver a strategy for resolving the {@link ReactiveAuthenticationManager}
104+
* by the issuer
105+
*/
106+
public JwtIssuerReactiveAuthenticationManagerResolver
107+
(ReactiveAuthenticationManagerResolver<String> issuerAuthenticationManagerResolver) {
108+
109+
Assert.notNull(issuerAuthenticationManagerResolver, "issuerAuthenticationManagerResolver cannot be null");
110+
this.issuerAuthenticationManagerResolver = issuerAuthenticationManagerResolver;
111+
}
112+
113+
/**
114+
* Return an {@link AuthenticationManager} based off of the `iss` claim found in the request's bearer token
115+
*
116+
* @throws OAuth2AuthenticationException if the bearer token is malformed or an {@link ReactiveAuthenticationManager}
117+
* can't be derived from the issuer
118+
*/
119+
@Override
120+
public Mono<ReactiveAuthenticationManager> resolve(ServerWebExchange exchange) {
121+
return this.issuerConverter.convert(exchange)
122+
.flatMap(issuer ->
123+
this.issuerAuthenticationManagerResolver.resolve(issuer).switchIfEmpty(
124+
Mono.error(new InvalidBearerTokenException("Invalid issuer " + issuer)))
125+
);
126+
}
127+
128+
private static class JwtClaimIssuerConverter
129+
implements Converter<ServerWebExchange, Mono<String>> {
130+
131+
private final ServerBearerTokenAuthenticationConverter converter =
132+
new ServerBearerTokenAuthenticationConverter();
133+
134+
@Override
135+
public Mono<String> convert(@NonNull ServerWebExchange exchange) {
136+
return this.converter.convert(exchange)
137+
.cast(BearerTokenAuthenticationToken.class)
138+
.flatMap(this::issuer);
139+
}
140+
141+
private Mono<String> issuer(BearerTokenAuthenticationToken token) {
142+
try {
143+
String issuer = JWTParser.parse(token.getToken()).getJWTClaimsSet().getIssuer();
144+
return Mono.justOrEmpty(issuer).switchIfEmpty(
145+
Mono.error(new InvalidBearerTokenException("Missing issuer")));
146+
} catch (Exception e) {
147+
return Mono.error(new InvalidBearerTokenException(e.getMessage()));
148+
}
149+
}
150+
}
151+
152+
private static class TrustedIssuerJwtAuthenticationManagerResolver
153+
implements ReactiveAuthenticationManagerResolver<String> {
154+
155+
private final Map<String, Mono<? extends ReactiveAuthenticationManager>> authenticationManagers =
156+
new ConcurrentHashMap<>();
157+
private final Predicate<String> trustedIssuer;
158+
159+
TrustedIssuerJwtAuthenticationManagerResolver(Predicate<String> trustedIssuer) {
160+
this.trustedIssuer = trustedIssuer;
161+
}
162+
163+
@Override
164+
public Mono<ReactiveAuthenticationManager> resolve(String issuer) {
165+
return Mono.just(issuer)
166+
.filter(this.trustedIssuer)
167+
.flatMap(iss ->
168+
this.authenticationManagers.computeIfAbsent(iss, k ->
169+
Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(iss))
170+
.subscribeOn(Schedulers.boundedElastic())
171+
.map(JwtReactiveAuthenticationManager::new)
172+
.cache())
173+
);
174+
}
175+
}
176+
}

0 commit comments

Comments
 (0)