Skip to content

Commit 3b15cf0

Browse files
committed
Ensure ID Token is updated after refresh token (Reactive)
Closes gh-17188 Signed-off-by: Evgeniy Cheban <mister.cheban@gmail.com>
1 parent 0eba9de commit 3b15cf0

18 files changed

Lines changed: 1105 additions & 22 deletions

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClient.java

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,17 @@
1717
package org.springframework.security.oauth2.client;
1818

1919
import java.io.Serializable;
20+
import java.util.Collections;
21+
import java.util.LinkedHashMap;
22+
import java.util.Map;
23+
24+
import org.jspecify.annotations.Nullable;
2025

21-
import org.springframework.lang.Nullable;
2226
import org.springframework.security.oauth2.client.registration.ClientRegistration;
2327
import org.springframework.security.oauth2.core.OAuth2AccessToken;
2428
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
2529
import org.springframework.util.Assert;
30+
import org.springframework.util.CollectionUtils;
2631

2732
/**
2833
* A representation of an OAuth 2.0 &quot;Authorized Client&quot;.
@@ -35,6 +40,7 @@
3540
* {@link #getPrincipalName() Resource Owner}.
3641
*
3742
* @author Joe Grandja
43+
* @author Evgeniy Cheban
3844
* @since 5.0
3945
* @see ClientRegistration
4046
* @see OAuth2AccessToken
@@ -52,6 +58,8 @@ public class OAuth2AuthorizedClient implements Serializable {
5258

5359
private final OAuth2RefreshToken refreshToken;
5460

61+
private final Map<String, Object> attributes;
62+
5563
/**
5664
* Constructs an {@code OAuth2AuthorizedClient} using the provided parameters.
5765
* @param clientRegistration the authorized client's registration
@@ -72,13 +80,29 @@ public OAuth2AuthorizedClient(ClientRegistration clientRegistration, String prin
7280
*/
7381
public OAuth2AuthorizedClient(ClientRegistration clientRegistration, String principalName,
7482
OAuth2AccessToken accessToken, @Nullable OAuth2RefreshToken refreshToken) {
83+
this(clientRegistration, principalName, accessToken, refreshToken, Collections.emptyMap());
84+
}
85+
86+
/**
87+
* Constructs an {@code OAuth2AuthorizedClient} using the provided parameters.
88+
* @param clientRegistration the authorized client's registration
89+
* @param principalName the name of the End-User {@code Principal} (Resource Owner)
90+
* @param accessToken the access token credential granted
91+
* @param refreshToken the refresh token credential granted
92+
* @param attributes the attributes associated with the authorized client
93+
* @since 7.1
94+
*/
95+
public OAuth2AuthorizedClient(ClientRegistration clientRegistration, String principalName,
96+
OAuth2AccessToken accessToken, @Nullable OAuth2RefreshToken refreshToken, Map<String, Object> attributes) {
7597
Assert.notNull(clientRegistration, "clientRegistration cannot be null");
7698
Assert.hasText(principalName, "principalName cannot be empty");
7799
Assert.notNull(accessToken, "accessToken cannot be null");
78100
this.clientRegistration = clientRegistration;
79101
this.principalName = principalName;
80102
this.accessToken = accessToken;
81103
this.refreshToken = refreshToken;
104+
this.attributes = CollectionUtils.isEmpty(attributes) ? Collections.emptyMap()
105+
: Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
82106
}
83107

84108
/**
@@ -114,4 +138,27 @@ public OAuth2AccessToken getAccessToken() {
114138
return this.refreshToken;
115139
}
116140

141+
/**
142+
* Returns the attributes associated with the authorized client.
143+
* @return a {@code Map} of the attributes associated with the authorized client
144+
* @since 7.1
145+
*/
146+
public Map<String, Object> getAttributes() {
147+
return this.attributes;
148+
}
149+
150+
/**
151+
* Returns the value of an attribute associated with the authorized client or
152+
* {@code null} if not available.
153+
* @param name the name of the attribute
154+
* @param <T> the type of the attribute
155+
* @return the value of the attribute associated with the authorized client
156+
* @since 7.1
157+
*/
158+
@Nullable
159+
@SuppressWarnings("unchecked")
160+
public <T> T getAttribute(String name) {
161+
return (T) this.attributes.get(name);
162+
}
163+
117164
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
/*
2+
* Copyright 2004-present 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.client;
18+
19+
import java.time.Duration;
20+
import java.util.Collection;
21+
import java.util.HashSet;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Set;
25+
26+
import reactor.core.publisher.Mono;
27+
28+
import org.springframework.security.core.Authentication;
29+
import org.springframework.security.core.GrantedAuthority;
30+
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
31+
import org.springframework.security.core.context.SecurityContext;
32+
import org.springframework.security.core.context.SecurityContextImpl;
33+
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
34+
import org.springframework.security.oauth2.client.oidc.authentication.ReactiveOidcIdTokenDecoderFactory;
35+
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
36+
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
37+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
38+
import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
39+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
40+
import org.springframework.security.oauth2.core.OAuth2Error;
41+
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
42+
import org.springframework.security.oauth2.core.oidc.OidcScopes;
43+
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
44+
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
45+
import org.springframework.security.oauth2.jwt.JwtException;
46+
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
47+
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory;
48+
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
49+
import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
50+
import org.springframework.util.Assert;
51+
import org.springframework.util.StringUtils;
52+
import org.springframework.web.server.ServerWebExchange;
53+
54+
/**
55+
* A {@link ReactiveOAuth2AuthorizationSuccessHandler} that refreshes an {@link OidcUser}
56+
* in the {@link SecurityContext} if the refreshed {@link OidcIdToken} is valid according
57+
* to <a href=
58+
* "https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse">OpenID
59+
* Connect Core 1.0 - Section 12.2 Successful Refresh Response</a>
60+
*
61+
* @author Evgeniy Cheban
62+
* @since 7.1
63+
*/
64+
public final class RefreshOidcUserReactiveOAuth2AuthorizationSuccessHandler
65+
implements ReactiveOAuth2AuthorizationSuccessHandler {
66+
67+
private static final String INVALID_ID_TOKEN_ERROR_CODE = "invalid_id_token";
68+
69+
private static final String INVALID_NONCE_ERROR_CODE = "invalid_nonce";
70+
71+
private static final String REFRESH_TOKEN_RESPONSE_ERROR_URI = "https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse";
72+
73+
// @formatter:off
74+
private static final Mono<ServerWebExchange> currentServerWebExchangeMono = Mono.deferContextual(Mono::just)
75+
.filter((c) -> c.hasKey(ServerWebExchange.class))
76+
.map((c) -> c.get(ServerWebExchange.class));
77+
// @formatter:on
78+
79+
private ServerSecurityContextRepository serverSecurityContextRepository = new WebSessionServerSecurityContextRepository();
80+
81+
private ReactiveJwtDecoderFactory<ClientRegistration> jwtDecoderFactory = new ReactiveOidcIdTokenDecoderFactory();
82+
83+
private ReactiveOAuth2UserService<OidcUserRequest, OidcUser> userService = new OidcReactiveOAuth2UserService();
84+
85+
private GrantedAuthoritiesMapper authoritiesMapper = (authorities) -> authorities;
86+
87+
private Duration clockSkew = Duration.ofSeconds(60);
88+
89+
@Override
90+
public Mono<Void> onAuthorizationSuccess(OAuth2AuthorizedClient authorizedClient, Authentication principal,
91+
Map<String, Object> attributes) {
92+
// The response must contain the openid scope.
93+
if (!authorizedClient.getAccessToken().getScopes().contains(OidcScopes.OPENID)) {
94+
return Mono.empty();
95+
}
96+
// The response must contain an id_token.
97+
String idToken = authorizedClient.getAttribute(OidcParameterNames.ID_TOKEN);
98+
if (!StringUtils.hasText(idToken)) {
99+
return Mono.empty();
100+
}
101+
if (!(principal instanceof OAuth2AuthenticationToken authenticationToken)
102+
|| authenticationToken.getClass() != OAuth2AuthenticationToken.class) {
103+
// If the application customizes the authentication result, then a custom
104+
// handler should be provided.
105+
return Mono.empty();
106+
}
107+
// The current principal must be an OidcUser.
108+
if (!(authenticationToken.getPrincipal() instanceof OidcUser existingOidcUser)) {
109+
return Mono.empty();
110+
}
111+
ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
112+
// The registrationId must match the one used to log in.
113+
if (!authenticationToken.getAuthorizedClientRegistrationId().equals(clientRegistration.getRegistrationId())) {
114+
return Mono.empty();
115+
}
116+
// Create, validate OidcIdToken and refresh OidcUser in the SecurityContext.
117+
return Mono.justOrEmpty((ServerWebExchange) attributes.get(ServerWebExchange.class.getName()))
118+
.switchIfEmpty(currentServerWebExchangeMono)
119+
.flatMap((exchange) -> {
120+
ReactiveJwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(clientRegistration);
121+
return jwtDecoder.decode(idToken).onErrorMap(JwtException.class, (ex) -> {
122+
OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, ex.getMessage(),
123+
null);
124+
return new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString(), ex);
125+
})
126+
.map((jwt) -> new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(),
127+
jwt.getClaims()))
128+
.doOnNext((oidcIdToken) -> validateIdToken(existingOidcUser, oidcIdToken))
129+
.flatMap((oidcIdToken) -> {
130+
OidcUserRequest userRequest = new OidcUserRequest(clientRegistration,
131+
authorizedClient.getAccessToken(), oidcIdToken);
132+
return this.userService.loadUser(userRequest);
133+
})
134+
.flatMap((oidcUser) -> refreshSecurityContext(exchange, clientRegistration, authenticationToken,
135+
oidcUser));
136+
});
137+
}
138+
139+
/**
140+
* Sets a {@link ServerSecurityContextRepository} to use for refreshing a
141+
* {@link SecurityContext}, defaults to
142+
* {@link WebSessionServerSecurityContextRepository}.
143+
* @param serverSecurityContextRepository the {@link ServerSecurityContextRepository}
144+
* to use
145+
*/
146+
public void setServerSecurityContextRepository(ServerSecurityContextRepository serverSecurityContextRepository) {
147+
Assert.notNull(serverSecurityContextRepository, "serverSecurityContextRepository cannot be null");
148+
this.serverSecurityContextRepository = serverSecurityContextRepository;
149+
}
150+
151+
/**
152+
* Sets a {@link ReactiveJwtDecoderFactory} to use for decoding refreshed oidc
153+
* id-token, defaults to {@link ReactiveOidcIdTokenDecoderFactory}.
154+
* @param jwtDecoderFactory the {@link ReactiveJwtDecoderFactory} to use
155+
*/
156+
public void setJwtDecoderFactory(ReactiveJwtDecoderFactory<ClientRegistration> jwtDecoderFactory) {
157+
Assert.notNull(jwtDecoderFactory, "jwtDecoderFactory cannot be null");
158+
this.jwtDecoderFactory = jwtDecoderFactory;
159+
}
160+
161+
/**
162+
* Sets a {@link GrantedAuthoritiesMapper} to use for mapping
163+
* {@link GrantedAuthority}s, defaults to no-op implementation.
164+
* @param authoritiesMapper the {@link GrantedAuthoritiesMapper} to use
165+
*/
166+
public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) {
167+
Assert.notNull(authoritiesMapper, "authoritiesMapper cannot be null");
168+
this.authoritiesMapper = authoritiesMapper;
169+
}
170+
171+
/**
172+
* Sets a {@link ReactiveOAuth2UserService} to use for loading an {@link OidcUser}
173+
* from refreshed oidc id-token, defaults to {@link OidcReactiveOAuth2UserService}.
174+
* @param userService the {@link ReactiveOAuth2UserService} to use
175+
*/
176+
public void setUserService(ReactiveOAuth2UserService<OidcUserRequest, OidcUser> userService) {
177+
Assert.notNull(userService, "userService cannot be null");
178+
this.userService = userService;
179+
}
180+
181+
/**
182+
* Sets the maximum acceptable clock skew, which is used when checking the
183+
* {@link OidcIdToken#getIssuedAt()} to match the existing
184+
* {@link OidcUser#getIdToken()}'s issuedAt time, defaults to 60 seconds.
185+
* @param clockSkew the maximum acceptable clock skew to use
186+
*/
187+
public void setClockSkew(Duration clockSkew) {
188+
Assert.notNull(clockSkew, "clockSkew cannot be null");
189+
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
190+
this.clockSkew = clockSkew;
191+
}
192+
193+
private void validateIdToken(OidcUser existingOidcUser, OidcIdToken idToken) {
194+
// OpenID Connect Core 1.0 - Section 12.2 Successful Refresh Response
195+
// If an ID Token is returned as a result of a token refresh request, the
196+
// following requirements apply:
197+
// its iss Claim Value MUST be the same as in the ID Token issued when the
198+
// original authentication occurred,
199+
validateIssuer(existingOidcUser, idToken);
200+
// its sub Claim Value MUST be the same as in the ID Token issued when the
201+
// original authentication occurred,
202+
validateSubject(existingOidcUser, idToken);
203+
// its iat Claim MUST represent the time that the new ID Token is issued,
204+
validateIssuedAt(existingOidcUser, idToken);
205+
// its aud Claim Value MUST be the same as in the ID Token issued when the
206+
// original authentication occurred,
207+
validateAudience(existingOidcUser, idToken);
208+
// if the ID Token contains an auth_time Claim, its value MUST represent the time
209+
// of the original authentication - not the time that the new ID token is issued,
210+
validateAuthenticatedAt(existingOidcUser, idToken);
211+
// it SHOULD NOT have a nonce Claim, even when the ID Token issued at the time of
212+
// the original authentication contained nonce; however, if it is present, its
213+
// value MUST be the same as in the ID Token issued at the time of the original
214+
// authentication,
215+
validateNonce(existingOidcUser, idToken);
216+
}
217+
218+
private void validateIssuer(OidcUser existingOidcUser, OidcIdToken idToken) {
219+
if (!idToken.getIssuer().toString().equals(existingOidcUser.getIdToken().getIssuer().toString())) {
220+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid issuer",
221+
REFRESH_TOKEN_RESPONSE_ERROR_URI);
222+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
223+
}
224+
}
225+
226+
private void validateSubject(OidcUser existingOidcUser, OidcIdToken idToken) {
227+
if (!idToken.getSubject().equals(existingOidcUser.getIdToken().getSubject())) {
228+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid subject",
229+
REFRESH_TOKEN_RESPONSE_ERROR_URI);
230+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
231+
}
232+
}
233+
234+
private void validateIssuedAt(OidcUser existingOidcUser, OidcIdToken idToken) {
235+
if (!idToken.getIssuedAt().isAfter(existingOidcUser.getIdToken().getIssuedAt().minus(this.clockSkew))) {
236+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid issued at time",
237+
REFRESH_TOKEN_RESPONSE_ERROR_URI);
238+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
239+
}
240+
}
241+
242+
private void validateAudience(OidcUser existingOidcUser, OidcIdToken idToken) {
243+
if (!isValidAudience(existingOidcUser, idToken)) {
244+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid audience",
245+
REFRESH_TOKEN_RESPONSE_ERROR_URI);
246+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
247+
}
248+
}
249+
250+
private boolean isValidAudience(OidcUser existingOidcUser, OidcIdToken idToken) {
251+
List<String> idTokenAudiences = idToken.getAudience();
252+
Set<String> oidcUserAudiences = new HashSet<>(existingOidcUser.getIdToken().getAudience());
253+
if (idTokenAudiences.size() != oidcUserAudiences.size()) {
254+
return false;
255+
}
256+
for (String audience : idTokenAudiences) {
257+
if (!oidcUserAudiences.contains(audience)) {
258+
return false;
259+
}
260+
}
261+
return true;
262+
}
263+
264+
private void validateAuthenticatedAt(OidcUser existingOidcUser, OidcIdToken idToken) {
265+
if (idToken.getAuthenticatedAt() == null) {
266+
return;
267+
}
268+
if (!idToken.getAuthenticatedAt().equals(existingOidcUser.getIdToken().getAuthenticatedAt())) {
269+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid authenticated at time",
270+
REFRESH_TOKEN_RESPONSE_ERROR_URI);
271+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
272+
}
273+
}
274+
275+
private void validateNonce(OidcUser existingOidcUser, OidcIdToken idToken) {
276+
if (!StringUtils.hasText(idToken.getNonce())) {
277+
return;
278+
}
279+
if (!idToken.getNonce().equals(existingOidcUser.getIdToken().getNonce())) {
280+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_NONCE_ERROR_CODE, "Invalid nonce",
281+
REFRESH_TOKEN_RESPONSE_ERROR_URI);
282+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
283+
}
284+
}
285+
286+
private Mono<Void> refreshSecurityContext(ServerWebExchange exchange, ClientRegistration clientRegistration,
287+
OAuth2AuthenticationToken authenticationToken, OidcUser oidcUser) {
288+
Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
289+
.mapAuthorities(oidcUser.getAuthorities());
290+
OAuth2AuthenticationToken authenticationResult = new OAuth2AuthenticationToken(oidcUser, mappedAuthorities,
291+
clientRegistration.getRegistrationId());
292+
authenticationResult.setDetails(authenticationToken.getDetails());
293+
SecurityContextImpl securityContext = new SecurityContextImpl(authenticationResult);
294+
return this.serverSecurityContextRepository.save(exchange, securityContext);
295+
}
296+
297+
}

0 commit comments

Comments
 (0)