Browse Source

Add additional validation when refreshing ID tokens

Issue gh-16589
Steve Riesenberg 5 months ago
parent
commit
860f130bc4

+ 23 - 15
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcUserRefreshedEventListenerConfigurationTests.java

@@ -171,7 +171,8 @@ public class OidcUserRefreshedEventListenerConfigurationTests {
 		given(this.refreshTokenAccessTokenResponseClient.getTokenResponse(any(OAuth2RefreshTokenGrantRequest.class)))
 			.willReturn(accessTokenResponse);
 
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(GOOGLE_CLIENT_REGISTRATION);
+		OAuth2AuthenticationToken authentication = createAuthenticationToken(GOOGLE_CLIENT_REGISTRATION,
+				createOidcUser());
 		OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
 			.withClientRegistrationId(GOOGLE_CLIENT_REGISTRATION.getRegistrationId())
 			.principal(authentication)
@@ -194,7 +195,8 @@ public class OidcUserRefreshedEventListenerConfigurationTests {
 		given(this.refreshTokenAccessTokenResponseClient.getTokenResponse(any(OAuth2RefreshTokenGrantRequest.class)))
 			.willReturn(accessTokenResponse);
 
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(GOOGLE_CLIENT_REGISTRATION);
+		OAuth2AuthenticationToken authentication = createAuthenticationToken(GOOGLE_CLIENT_REGISTRATION,
+				createOidcUser());
 		OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
 			.withClientRegistrationId(GOOGLE_CLIENT_REGISTRATION.getRegistrationId())
 			.principal(authentication)
@@ -297,7 +299,8 @@ public class OidcUserRefreshedEventListenerConfigurationTests {
 		given(this.refreshTokenAccessTokenResponseClient.getTokenResponse(any(OAuth2RefreshTokenGrantRequest.class)))
 			.willReturn(accessTokenResponse);
 
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(GITHUB_CLIENT_REGISTRATION);
+		OAuth2AuthenticationToken authentication = createAuthenticationToken(GITHUB_CLIENT_REGISTRATION,
+				createOidcUser());
 		SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
 		SecurityContextHolder.setContext(securityContext);
 
@@ -316,17 +319,17 @@ public class OidcUserRefreshedEventListenerConfigurationTests {
 
 		OAuth2AuthorizedClient authorizedClient = createAuthorizedClient();
 		OAuth2AccessTokenResponse accessTokenResponse = createAccessTokenResponse(OidcScopes.OPENID);
-		Jwt jwt = createJwt();
-		OidcUser oidcUser = createOidcUser();
+		Jwt jwt = createJwt().build();
 		given(this.authorizedClientRepository.loadAuthorizedClient(anyString(), any(Authentication.class),
 				any(HttpServletRequest.class)))
 			.willReturn(authorizedClient);
 		given(this.refreshTokenAccessTokenResponseClient.getTokenResponse(any(OAuth2RefreshTokenGrantRequest.class)))
 			.willReturn(accessTokenResponse);
 		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
-		given(this.oidcUserService.loadUser(any(OidcUserRequest.class))).willReturn(oidcUser);
+		given(this.oidcUserService.loadUser(any(OidcUserRequest.class))).willReturn(createOidcUser());
 
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(GOOGLE_CLIENT_REGISTRATION);
+		OAuth2AuthenticationToken authentication = createAuthenticationToken(GOOGLE_CLIENT_REGISTRATION,
+				createOidcUser());
 		SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
 		SecurityContextHolder.setContext(securityContext);
 
@@ -405,31 +408,36 @@ public class OidcUserRefreshedEventListenerConfigurationTests {
 			.build();
 	}
 
-	private Jwt createJwt() {
+	private static Jwt.Builder createJwt() {
 		Instant issuedAt = Instant.now();
 		Instant expiresAt = issuedAt.plus(1, ChronoUnit.MINUTES);
 		return TestJwts.jwt()
+			.issuer("https://surf.school")
 			.subject(SUBJECT)
 			.tokenValue(ID_TOKEN_VALUE)
 			.issuedAt(issuedAt)
 			.expiresAt(expiresAt)
-			.build();
+			.audience(List.of("audience1", "audience2"));
 	}
 
-	private OidcUser createOidcUser() {
+	private static OidcUser createOidcUser() {
+		Instant issuedAt = Instant.now().minus(30, ChronoUnit.SECONDS);
+		Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES);
 		Map<String, Object> claims = new HashMap<>();
+		claims.put(IdTokenClaimNames.ISS, "https://surf.school");
 		claims.put(IdTokenClaimNames.SUB, SUBJECT);
-		claims.put(IdTokenClaimNames.ISS, "issuer");
+		claims.put(IdTokenClaimNames.IAT, issuedAt);
+		claims.put(IdTokenClaimNames.EXP, expiresAt);
 		claims.put(IdTokenClaimNames.AUD, List.of("audience1", "audience2"));
-		Instant issuedAt = Instant.now();
-		Instant expiresAt = issuedAt.plus(1, ChronoUnit.MINUTES);
+		claims.put(IdTokenClaimNames.AUTH_TIME, issuedAt);
+		claims.put(IdTokenClaimNames.NONCE, "nonce");
 		OidcIdToken idToken = new OidcIdToken(ID_TOKEN_VALUE, issuedAt, expiresAt, claims);
 
 		return new DefaultOidcUser(AuthorityUtils.createAuthorityList("OIDC_USER"), idToken);
 	}
 
-	private OAuth2AuthenticationToken createAuthenticationToken(ClientRegistration clientRegistration) {
-		OidcUser oidcUser = createOidcUser();
+	private OAuth2AuthenticationToken createAuthenticationToken(ClientRegistration clientRegistration,
+			OidcUser oidcUser) {
 		return new OAuth2AuthenticationToken(oidcUser, oidcUser.getAuthorities(),
 				clientRegistration.getRegistrationId());
 	}

+ 106 - 3
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListener.java

@@ -16,8 +16,12 @@
 
 package org.springframework.security.oauth2.client.oidc.authentication;
 
+import java.time.Duration;
 import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.ApplicationEventPublisherAware;
@@ -67,6 +71,8 @@ public final class OidcAuthorizedClientRefreshedEventListener
 
 	private static final String INVALID_NONCE_ERROR_CODE = "invalid_nonce";
 
+	private static final String REFRESH_TOKEN_RESPONSE_ERROR_URI = "https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse";
+
 	private OAuth2UserService<OidcUserRequest, OidcUser> userService = new OidcUserService();
 
 	private JwtDecoderFactory<ClientRegistration> jwtDecoderFactory = new OidcIdTokenDecoderFactory();
@@ -78,6 +84,8 @@ public final class OidcAuthorizedClientRefreshedEventListener
 
 	private ApplicationEventPublisher applicationEventPublisher;
 
+	private Duration clockSkew = Duration.ofSeconds(60);
+
 	@Override
 	public void onApplicationEvent(OAuth2AuthorizedClientRefreshedEvent event) {
 		if (this.applicationEventPublisher == null) {
@@ -119,7 +127,7 @@ public final class OidcAuthorizedClientRefreshedEventListener
 
 		// Refresh the OidcUser and send a user refreshed event
 		OidcIdToken idToken = createOidcToken(clientRegistration, accessTokenResponse);
-		validateNonce(existingOidcUser, idToken);
+		validateIdToken(existingOidcUser, idToken);
 		OidcUserRequest userRequest = new OidcUserRequest(clientRegistration, accessTokenResponse.getAccessToken(),
 				idToken, additionalParameters);
 		OidcUser oidcUser = this.userService.loadUser(userRequest);
@@ -187,6 +195,17 @@ public final class OidcAuthorizedClientRefreshedEventListener
 		this.applicationEventPublisher = applicationEventPublisher;
 	}
 
+	/**
+	 * Sets the maximum acceptable clock skew, which is used when checking the
+	 * {@link OidcIdToken#getIssuedAt() issuedAt} time. The default is 60 seconds.
+	 * @param clockSkew the maximum acceptable clock skew
+	 */
+	public void setClockSkew(Duration clockSkew) {
+		Assert.notNull(clockSkew, "clockSkew cannot be null");
+		Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
+		this.clockSkew = clockSkew;
+	}
+
 	private OidcIdToken createOidcToken(ClientRegistration clientRegistration,
 			OAuth2AccessTokenResponse accessTokenResponse) {
 		JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(clientRegistration);
@@ -205,13 +224,97 @@ public final class OidcAuthorizedClientRefreshedEventListener
 		}
 	}
 
+	private void validateIdToken(OidcUser existingOidcUser, OidcIdToken idToken) {
+		// OpenID Connect Core 1.0 - Section 12.2 Successful Refresh Response
+		// If an ID Token is returned as a result of a token refresh request, the
+		// following requirements apply:
+		// its iss Claim Value MUST be the same as in the ID Token issued when the
+		// original authentication occurred,
+		validateIssuer(existingOidcUser, idToken);
+		// its sub Claim Value MUST be the same as in the ID Token issued when the
+		// original authentication occurred,
+		validateSubject(existingOidcUser, idToken);
+		// its iat Claim MUST represent the time that the new ID Token is issued,
+		validateIssuedAt(existingOidcUser, idToken);
+		// its aud Claim Value MUST be the same as in the ID Token issued when the
+		// original authentication occurred,
+		validateAudience(existingOidcUser, idToken);
+		// if the ID Token contains an auth_time Claim, its value MUST represent the time
+		// of the original authentication - not the time that the new ID token is issued,
+		validateAuthenticatedAt(existingOidcUser, idToken);
+		// it SHOULD NOT have a nonce Claim, even when the ID Token issued at the time of
+		// the original authentication contained nonce; however, if it is present, its
+		// value MUST be the same as in the ID Token issued at the time of the original
+		// authentication,
+		validateNonce(existingOidcUser, idToken);
+	}
+
+	private void validateIssuer(OidcUser existingOidcUser, OidcIdToken idToken) {
+		if (!idToken.getIssuer().toString().equals(existingOidcUser.getIdToken().getIssuer().toString())) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid issuer",
+					REFRESH_TOKEN_RESPONSE_ERROR_URI);
+			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
+		}
+	}
+
+	private void validateSubject(OidcUser existingOidcUser, OidcIdToken idToken) {
+		if (!idToken.getSubject().equals(existingOidcUser.getIdToken().getSubject())) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid subject",
+					REFRESH_TOKEN_RESPONSE_ERROR_URI);
+			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
+		}
+	}
+
+	private void validateIssuedAt(OidcUser existingOidcUser, OidcIdToken idToken) {
+		if (!idToken.getIssuedAt().isAfter(existingOidcUser.getIdToken().getIssuedAt().minus(this.clockSkew))) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid issued at time",
+					REFRESH_TOKEN_RESPONSE_ERROR_URI);
+			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
+		}
+	}
+
+	private void validateAudience(OidcUser existingOidcUser, OidcIdToken idToken) {
+		if (!isValidAudience(existingOidcUser, idToken)) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid audience",
+					REFRESH_TOKEN_RESPONSE_ERROR_URI);
+			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
+		}
+	}
+
+	private boolean isValidAudience(OidcUser existingOidcUser, OidcIdToken idToken) {
+		List<String> idTokenAudiences = idToken.getAudience();
+		Set<String> oidcUserAudiences = new HashSet<>(existingOidcUser.getIdToken().getAudience());
+		if (idTokenAudiences.size() != oidcUserAudiences.size()) {
+			return false;
+		}
+		for (String audience : idTokenAudiences) {
+			if (!oidcUserAudiences.contains(audience)) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	private void validateAuthenticatedAt(OidcUser existingOidcUser, OidcIdToken idToken) {
+		if (idToken.getAuthenticatedAt() == null) {
+			return;
+		}
+
+		if (!idToken.getAuthenticatedAt().equals(existingOidcUser.getIdToken().getAuthenticatedAt())) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE, "Invalid authenticated at time",
+					REFRESH_TOKEN_RESPONSE_ERROR_URI);
+			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
+		}
+	}
+
 	private void validateNonce(OidcUser existingOidcUser, OidcIdToken idToken) {
 		if (!StringUtils.hasText(idToken.getNonce())) {
 			return;
 		}
 
-		if (!idToken.getNonce().equals(existingOidcUser.getNonce())) {
-			OAuth2Error oauth2Error = new OAuth2Error(INVALID_NONCE_ERROR_CODE);
+		if (!idToken.getNonce().equals(existingOidcUser.getIdToken().getNonce())) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_NONCE_ERROR_CODE, "Invalid nonce",
+					REFRESH_TOKEN_RESPONSE_ERROR_URI);
 			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
 		}
 	}

+ 211 - 29
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListenerTests.java

@@ -16,6 +16,7 @@
 
 package org.springframework.security.oauth2.client.oidc.authentication;
 
+import java.time.Duration;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.Collection;
@@ -80,6 +81,10 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
  */
 public class OidcAuthorizedClientRefreshedEventListenerTests {
 
+	private static final String INVALID_ID_TOKEN_ERROR = "invalid_id_token";
+
+	private static final String INVALID_NONCE_ERROR = "invalid_nonce";
+
 	private static final String SUBJECT = "surfer-dude";
 
 	private static final String ACCESS_TOKEN_VALUE = "hang-ten";
@@ -108,6 +113,8 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 
 	private OidcUser oidcUser;
 
+	private OAuth2AuthenticationToken authentication;
+
 	@BeforeEach
 	public void setUp() {
 		this.jwtDecoder = mock(JwtDecoder.class);
@@ -124,38 +131,72 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 		this.clientRegistration = TestClientRegistrations.clientRegistration().scope(OidcScopes.OPENID).build();
 		this.authorizedClient = createAuthorizedClient(this.clientRegistration);
 		this.accessTokenResponse = createAccessTokenResponse(OidcScopes.OPENID);
-		this.jwt = createJwt();
+		this.jwt = createJwt().build();
 		this.oidcUser = createOidcUser();
+		this.authentication = createAuthenticationToken(this.clientRegistration, createOidcUser());
 	}
 
 	@Test
 	public void setSecurityContextHolderStrategyWhenNullThenThrowsIllegalArgumentException() {
-		assertThatIllegalArgumentException().isThrownBy(() -> this.eventListener.setSecurityContextHolderStrategy(null))
+		// @formatter:off
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.eventListener.setSecurityContextHolderStrategy(null))
 			.withMessage("securityContextHolderStrategy cannot be null");
+		// @formatter:on
 	}
 
 	@Test
 	public void setJwtDecoderFactoryWhenNullThenThrowsIllegalArgumentException() {
-		assertThatIllegalArgumentException().isThrownBy(() -> this.eventListener.setJwtDecoderFactory(null))
+		// @formatter:off
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.eventListener.setJwtDecoderFactory(null))
 			.withMessage("jwtDecoderFactory cannot be null");
+		// @formatter:on
 	}
 
 	@Test
 	public void setUserServiceWhenNullThenThrowsIllegalArgumentException() {
-		assertThatIllegalArgumentException().isThrownBy(() -> this.eventListener.setUserService(null))
+		// @formatter:off
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.eventListener.setUserService(null))
 			.withMessage("userService cannot be null");
+		// @formatter:on
 	}
 
 	@Test
 	public void setAuthoritiesMapperWhenNullThenThrowsIllegalArgumentException() {
-		assertThatIllegalArgumentException().isThrownBy(() -> this.eventListener.setAuthoritiesMapper(null))
+		// @formatter:off
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.eventListener.setAuthoritiesMapper(null))
 			.withMessage("authoritiesMapper cannot be null");
+		// @formatter:on
 	}
 
 	@Test
 	public void setApplicationEventPublisherWhenNullThenThrowsIllegalArgumentException() {
-		assertThatIllegalArgumentException().isThrownBy(() -> this.eventListener.setApplicationEventPublisher(null))
+		// @formatter:off
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.eventListener.setApplicationEventPublisher(null))
 			.withMessage("applicationEventPublisher cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setClockSkewWhenNullThenThrowsIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.eventListener.setClockSkew(null))
+			.withMessage("clockSkew cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setClockSkewWhenNegativeThenThrowsIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.eventListener.setClockSkew(Duration.ofMillis(-1)))
+			.withMessage("clockSkew must be >= 0");
+		// @formatter:on
 	}
 
 	@Test
@@ -237,7 +278,7 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration()
 			.registrationId("test")
 			.build();
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(clientRegistration);
+		OAuth2AuthenticationToken authentication = createAuthenticationToken(clientRegistration, this.oidcUser);
 		SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
 		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
 
@@ -251,9 +292,8 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 	}
 
 	@Test
-	public void onApplicationEventWhenAccessTokenResponseIncludesIdTokenThenPublishOidcUserRefreshedEvent() {
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(this.clientRegistration);
-		SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
+	public void onApplicationEventWhenAccessTokenResponseIncludesIdTokenThenOidcUserRefreshedEventPublished() {
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
 		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
 		given(this.jwtDecoder.decode(anyString())).willReturn(this.jwt);
 		given(this.userService.loadUser(any(OidcUserRequest.class))).willReturn(this.oidcUser);
@@ -279,9 +319,10 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 
 		OidcUserRefreshedEvent userRefreshedEvent = userRefreshedEventCaptor.getValue();
 		assertThat(userRefreshedEvent.getAccessTokenResponse()).isSameAs(this.accessTokenResponse);
-		assertThat(userRefreshedEvent.getOldOidcUser()).isSameAs(authentication.getPrincipal());
+		assertThat(userRefreshedEvent.getOldOidcUser()).isSameAs(this.authentication.getPrincipal());
 		assertThat(userRefreshedEvent.getNewOidcUser()).isSameAs(this.oidcUser);
-		assertThat(userRefreshedEvent.getAuthentication()).isNotSameAs(authentication);
+		assertThat(userRefreshedEvent.getOldOidcUser()).isNotSameAs(userRefreshedEvent.getNewOidcUser());
+		assertThat(userRefreshedEvent.getAuthentication()).isNotSameAs(this.authentication);
 		assertThat(userRefreshedEvent.getAuthentication()).isInstanceOf(OAuth2AuthenticationToken.class);
 
 		OAuth2AuthenticationToken authenticationResult = (OAuth2AuthenticationToken) userRefreshedEvent
@@ -292,11 +333,134 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 			.isEqualTo(this.clientRegistration.getRegistrationId());
 	}
 
+	@Test
+	public void onApplicationEventWhenIdTokenIssuerDoesNotMatchThenThrowsOAuth2AuthenticationException() {
+		Jwt jwt = createJwt().issuer("https://invalid.url").build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
+		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
+		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
+
+		OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent(
+				this.accessTokenResponse, this.authorizedClient);
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+			.isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent))
+			.withMessageContaining("Invalid issuer")
+			.extracting(OAuth2AuthenticationException::getError)
+			.extracting(OAuth2Error::getErrorCode)
+			.isEqualTo(INVALID_ID_TOKEN_ERROR);
+		verify(this.securityContextHolderStrategy).getContext();
+		verify(this.jwtDecoder).decode(this.jwt.getTokenValue());
+		verifyNoMoreInteractions(this.securityContextHolderStrategy, this.jwtDecoder);
+		verifyNoInteractions(this.userService, this.applicationEventPublisher);
+	}
+
+	@Test
+	public void onApplicationEventWhenIdTokenSubjectDoesNotMatchThenThrowsOAuth2AuthenticationException() {
+		Jwt jwt = createJwt().subject("invalid").build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
+		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
+		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
+
+		OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent(
+				this.accessTokenResponse, this.authorizedClient);
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+			.isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent))
+			.withMessageContaining("Invalid subject")
+			.extracting(OAuth2AuthenticationException::getError)
+			.extracting(OAuth2Error::getErrorCode)
+			.isEqualTo(INVALID_ID_TOKEN_ERROR);
+		verify(this.securityContextHolderStrategy).getContext();
+		verify(this.jwtDecoder).decode(this.jwt.getTokenValue());
+		verifyNoMoreInteractions(this.securityContextHolderStrategy, this.jwtDecoder);
+		verifyNoInteractions(this.userService, this.applicationEventPublisher);
+	}
+
+	@Test
+	public void onApplicationEventWhenIdTokenIssuedAtIsBeforeThenThrowsOAuth2AuthenticationException() {
+		Instant issuedAt = this.oidcUser.getIssuedAt().minus(2, ChronoUnit.MINUTES);
+		Jwt jwt = createJwt().issuedAt(issuedAt).build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
+		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
+		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
+
+		OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent(
+				this.accessTokenResponse, this.authorizedClient);
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+			.isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent))
+			.withMessageContaining("Invalid issued at time")
+			.extracting(OAuth2AuthenticationException::getError)
+			.extracting(OAuth2Error::getErrorCode)
+			.isEqualTo(INVALID_ID_TOKEN_ERROR);
+		verify(this.securityContextHolderStrategy).getContext();
+		verify(this.jwtDecoder).decode(this.jwt.getTokenValue());
+		verifyNoMoreInteractions(this.securityContextHolderStrategy, this.jwtDecoder);
+		verifyNoInteractions(this.userService, this.applicationEventPublisher);
+	}
+
+	@Test
+	public void onApplicationEventWhenIdTokenAudienceDoesNotMatchThenThrowsOAuth2AuthenticationException() {
+		Jwt jwt = createJwt().audience(List.of("audience1", "audience3")).build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
+		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
+		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
+
+		OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent(
+				this.accessTokenResponse, this.authorizedClient);
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+			.isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent))
+			.withMessageContaining("Invalid audience")
+			.extracting(OAuth2AuthenticationException::getError)
+			.extracting(OAuth2Error::getErrorCode)
+			.isEqualTo(INVALID_ID_TOKEN_ERROR);
+		verify(this.securityContextHolderStrategy).getContext();
+		verify(this.jwtDecoder).decode(this.jwt.getTokenValue());
+		verifyNoMoreInteractions(this.securityContextHolderStrategy, this.jwtDecoder);
+		verifyNoInteractions(this.userService, this.applicationEventPublisher);
+	}
+
+	@Test
+	public void onApplicationEventWhenIdTokenAuthenticatedAtDoesNotMatchThenThrowsOAuth2AuthenticationException() {
+		Instant authTime = this.oidcUser.getAuthenticatedAt().plus(5, ChronoUnit.SECONDS);
+		Jwt jwt = createJwt().claim(IdTokenClaimNames.AUTH_TIME, authTime).build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
+		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
+		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
+
+		OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent(
+				this.accessTokenResponse, this.authorizedClient);
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+			.isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent))
+			.withMessageContaining("Invalid authenticated at time")
+			.extracting(OAuth2AuthenticationException::getError)
+			.extracting(OAuth2Error::getErrorCode)
+			.isEqualTo(INVALID_ID_TOKEN_ERROR);
+		verify(this.securityContextHolderStrategy).getContext();
+		verify(this.jwtDecoder).decode(this.jwt.getTokenValue());
+		verifyNoMoreInteractions(this.securityContextHolderStrategy, this.jwtDecoder);
+		verifyNoInteractions(this.userService, this.applicationEventPublisher);
+	}
+
+	@Test
+	public void onApplicationEventWhenIdTokenAuthenticatedAtMatchesThenOidcUserRefreshedEventPublished() {
+		Instant authTime = this.authentication.getPrincipal().getAttribute(IdTokenClaimNames.AUTH_TIME);
+		Jwt jwt = createJwt().claim(IdTokenClaimNames.AUTH_TIME, authTime).build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
+		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
+		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
+		given(this.userService.loadUser(any(OidcUserRequest.class))).willReturn(this.oidcUser);
+
+		OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent(
+				this.accessTokenResponse, this.authorizedClient);
+		this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent);
+
+		verify(this.applicationEventPublisher).publishEvent(any(OidcUserRefreshedEvent.class));
+		verifyNoMoreInteractions(this.applicationEventPublisher);
+	}
+
 	@Test
 	public void onApplicationEventWhenIdTokenNonceDoesNotMatchThenThrowsOAuth2AuthenticationException() {
-		Jwt jwt = TestJwts.jwt().claim(IdTokenClaimNames.NONCE, "invalid").build();
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(this.clientRegistration);
-		SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
+		Jwt jwt = createJwt().claim(IdTokenClaimNames.NONCE, "invalid").build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
 		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
 		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
 
@@ -304,19 +468,35 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 				this.accessTokenResponse, this.authorizedClient);
 		assertThatExceptionOfType(OAuth2AuthenticationException.class)
 			.isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent))
+			.withMessageContaining("Invalid nonce")
 			.extracting(OAuth2AuthenticationException::getError)
 			.extracting(OAuth2Error::getErrorCode)
-			.isEqualTo("invalid_nonce");
+			.isEqualTo(INVALID_NONCE_ERROR);
 		verify(this.securityContextHolderStrategy).getContext();
 		verify(this.jwtDecoder).decode(this.jwt.getTokenValue());
 		verifyNoMoreInteractions(this.securityContextHolderStrategy, this.jwtDecoder);
 		verifyNoInteractions(this.userService, this.applicationEventPublisher);
 	}
 
+	@Test
+	public void onApplicationEventWhenIdTokenNonceMatchesThenOidcUserRefreshedEventPublished() {
+		Jwt jwt = createJwt().claim(IdTokenClaimNames.NONCE, this.oidcUser.getNonce()).build();
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
+		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
+		given(this.jwtDecoder.decode(anyString())).willReturn(jwt);
+		given(this.userService.loadUser(any(OidcUserRequest.class))).willReturn(this.oidcUser);
+
+		OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent(
+				this.accessTokenResponse, this.authorizedClient);
+		this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent);
+
+		verify(this.applicationEventPublisher).publishEvent(any(OidcUserRefreshedEvent.class));
+		verifyNoMoreInteractions(this.applicationEventPublisher);
+	}
+
 	@Test
 	public void onApplicationEventWhenInvalidIdTokenThenThrowsOAuth2AuthenticationException() {
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(this.clientRegistration);
-		SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
 		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
 		given(this.jwtDecoder.decode(anyString())).willThrow(new JwtException("Invalid token"));
 
@@ -326,7 +506,7 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 			.isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent))
 			.extracting(OAuth2AuthenticationException::getError)
 			.extracting(OAuth2Error::getErrorCode)
-			.isEqualTo("invalid_id_token");
+			.isEqualTo(INVALID_ID_TOKEN_ERROR);
 		verify(this.securityContextHolderStrategy).getContext();
 		verify(this.jwtDecoder).decode(this.jwt.getTokenValue());
 		verifyNoMoreInteractions(this.securityContextHolderStrategy, this.jwtDecoder);
@@ -335,8 +515,7 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 
 	@Test
 	public void onApplicationEventWhenCustomAuthoritiesMapperSetThenUsed() {
-		OAuth2AuthenticationToken authentication = createAuthenticationToken(this.clientRegistration);
-		SecurityContextImpl securityContext = new SecurityContextImpl(authentication);
+		SecurityContextImpl securityContext = new SecurityContextImpl(this.authentication);
 		given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext);
 		given(this.jwtDecoder.decode(anyString())).willReturn(this.jwt);
 		given(this.userService.loadUser(any(OidcUserRequest.class))).willReturn(this.oidcUser);
@@ -377,33 +556,36 @@ public class OidcAuthorizedClientRefreshedEventListenerTests {
 			.build();
 	}
 
-	private static Jwt createJwt() {
+	private static Jwt.Builder createJwt() {
 		Instant issuedAt = Instant.now();
 		Instant expiresAt = issuedAt.plus(1, ChronoUnit.MINUTES);
 		return TestJwts.jwt()
+			.issuer("https://surf.school")
 			.subject(SUBJECT)
 			.tokenValue(ID_TOKEN_VALUE)
 			.issuedAt(issuedAt)
 			.expiresAt(expiresAt)
-			.claim(OidcParameterNames.NONCE, "nonce")
-			.build();
+			.audience(List.of("audience1", "audience2"));
 	}
 
 	private static OidcUser createOidcUser() {
+		Instant issuedAt = Instant.now().minus(30, ChronoUnit.SECONDS);
+		Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES);
 		Map<String, Object> claims = new HashMap<>();
+		claims.put(IdTokenClaimNames.ISS, "https://surf.school");
 		claims.put(IdTokenClaimNames.SUB, SUBJECT);
-		claims.put(IdTokenClaimNames.ISS, "issuer");
+		claims.put(IdTokenClaimNames.IAT, issuedAt);
+		claims.put(IdTokenClaimNames.EXP, expiresAt);
 		claims.put(IdTokenClaimNames.AUD, List.of("audience1", "audience2"));
+		claims.put(IdTokenClaimNames.AUTH_TIME, issuedAt);
 		claims.put(IdTokenClaimNames.NONCE, "nonce");
-		Instant issuedAt = Instant.now();
-		Instant expiresAt = issuedAt.plus(1, ChronoUnit.MINUTES);
 		OidcIdToken idToken = new OidcIdToken(ID_TOKEN_VALUE, issuedAt, expiresAt, claims);
 
 		return new DefaultOidcUser(AuthorityUtils.createAuthorityList("OIDC_USER"), idToken);
 	}
 
-	private static OAuth2AuthenticationToken createAuthenticationToken(ClientRegistration clientRegistration) {
-		OidcUser oidcUser = createOidcUser();
+	private static OAuth2AuthenticationToken createAuthenticationToken(ClientRegistration clientRegistration,
+			OidcUser oidcUser) {
 		return new OAuth2AuthenticationToken(oidcUser, oidcUser.getAuthorities(),
 				clientRegistration.getRegistrationId());
 	}