diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcUserRefreshedEventListenerConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcUserRefreshedEventListenerConfigurationTests.java index 5ad7f9726e..df28e06209 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcUserRefreshedEventListenerConfigurationTests.java +++ b/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 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()); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListener.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListener.java index b6f8c45b49..6f9e320fb6 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListener.java +++ b/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 userService = new OidcUserService(); private JwtDecoderFactory 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 idTokenAudiences = idToken.getAudience(); + Set 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()); } } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListenerTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListenerTests.java index 64a94a8f37..1e34355de0 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizedClientRefreshedEventListenerTests.java +++ b/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 @@ -293,10 +334,9 @@ public class OidcAuthorizedClientRefreshedEventListenerTests { } @Test - public void onApplicationEventWhenIdTokenNonceDoesNotMatchThenThrowsOAuth2AuthenticationException() { - Jwt jwt = TestJwts.jwt().claim(IdTokenClaimNames.NONCE, "invalid").build(); - OAuth2AuthenticationToken authentication = createAuthenticationToken(this.clientRegistration); - SecurityContextImpl securityContext = new SecurityContextImpl(authentication); + 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); @@ -304,19 +344,159 @@ public class OidcAuthorizedClientRefreshedEventListenerTests { this.accessTokenResponse, this.authorizedClient); assertThatExceptionOfType(OAuth2AuthenticationException.class) .isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent)) + .withMessageContaining("Invalid issuer") .extracting(OAuth2AuthenticationException::getError) .extracting(OAuth2Error::getErrorCode) - .isEqualTo("invalid_nonce"); + .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 = 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); + + OAuth2AuthorizedClientRefreshedEvent authorizedClientRefreshedEvent = new OAuth2AuthorizedClientRefreshedEvent( + this.accessTokenResponse, this.authorizedClient); + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.eventListener.onApplicationEvent(authorizedClientRefreshedEvent)) + .withMessageContaining("Invalid nonce") + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .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 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()); }