Skip to content

Commit 63b2016

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 63b2016

10 files changed

Lines changed: 977 additions & 13 deletions

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
* {@link #getPrincipalName() Resource Owner}.
3636
*
3737
* @author Joe Grandja
38+
* @author Evgeniy Cheban
3839
* @since 5.0
3940
* @see ClientRegistration
4041
* @see OAuth2AccessToken
@@ -52,6 +53,8 @@ public class OAuth2AuthorizedClient implements Serializable {
5253

5354
private final OAuth2RefreshToken refreshToken;
5455

56+
private final String idToken;
57+
5558
/**
5659
* Constructs an {@code OAuth2AuthorizedClient} using the provided parameters.
5760
* @param clientRegistration the authorized client's registration
@@ -72,13 +75,29 @@ public OAuth2AuthorizedClient(ClientRegistration clientRegistration, String prin
7275
*/
7376
public OAuth2AuthorizedClient(ClientRegistration clientRegistration, String principalName,
7477
OAuth2AccessToken accessToken, @Nullable OAuth2RefreshToken refreshToken) {
78+
this(clientRegistration, principalName, accessToken, refreshToken, null);
79+
}
80+
81+
/**
82+
* Constructs an {@code OAuth2AuthorizedClient} using the provided parameters.
83+
* @param clientRegistration the authorized client's registration
84+
* @param principalName the name of the End-User {@code Principal} (Resource Owner)
85+
* @param accessToken the access token credential granted
86+
* @param refreshToken the refresh token credential granted
87+
* @param idToken the {@literal id_token} value associated with the authenticated
88+
* session.
89+
* @since 7.1
90+
*/
91+
public OAuth2AuthorizedClient(ClientRegistration clientRegistration, String principalName,
92+
OAuth2AccessToken accessToken, @Nullable OAuth2RefreshToken refreshToken, @Nullable String idToken) {
7593
Assert.notNull(clientRegistration, "clientRegistration cannot be null");
7694
Assert.hasText(principalName, "principalName cannot be empty");
7795
Assert.notNull(accessToken, "accessToken cannot be null");
7896
this.clientRegistration = clientRegistration;
7997
this.principalName = principalName;
8098
this.accessToken = accessToken;
8199
this.refreshToken = refreshToken;
100+
this.idToken = idToken;
82101
}
83102

84103
/**
@@ -114,4 +133,13 @@ public OAuth2AccessToken getAccessToken() {
114133
return this.refreshToken;
115134
}
116135

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

0 commit comments

Comments
 (0)