diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizationCodeAuthenticationProvider.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizationCodeAuthenticationProvider.java index ba35276934..ff1361f4d0 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizationCodeAuthenticationProvider.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcAuthorizationCodeAuthenticationProvider.java @@ -15,6 +15,10 @@ */ package org.springframework.security.oauth2.client.oidc.authentication; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -40,16 +44,8 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoderJwkSupport; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; -import java.net.URL; -import java.time.Instant; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - /** * An implementation of an {@link AuthenticationProvider} * for the OpenID Connect Core 1.0 Authorization Code Grant Flow. @@ -151,11 +147,7 @@ public class OidcAuthorizationCodeAuthenticationProvider implements Authenticati throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString()); } - JwtDecoder jwtDecoder = this.getJwtDecoder(clientRegistration); - Jwt jwt = jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN)); - OidcIdToken idToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims()); - - this.validateIdToken(idToken, clientRegistration); + OidcIdToken idToken = createOidcToken(clientRegistration, accessTokenResponse); OidcUser oidcUser = this.userService.loadUser( new OidcUserRequest(clientRegistration, accessTokenResponse.getAccessToken(), idToken)); @@ -191,15 +183,24 @@ public class OidcAuthorizationCodeAuthenticationProvider implements Authenticati return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication); } + private OidcIdToken createOidcToken(ClientRegistration clientRegistration, OAuth2AccessTokenResponse accessTokenResponse) { + JwtDecoder jwtDecoder = getJwtDecoder(clientRegistration); + Jwt jwt = jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get( + OidcParameterNames.ID_TOKEN)); + OidcIdToken idToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims()); + OidcTokenValidator.validateIdToken(idToken, clientRegistration); + return idToken; + } + private JwtDecoder getJwtDecoder(ClientRegistration clientRegistration) { JwtDecoder jwtDecoder = this.jwtDecoders.get(clientRegistration.getRegistrationId()); if (jwtDecoder == null) { if (!StringUtils.hasText(clientRegistration.getProviderDetails().getJwkSetUri())) { OAuth2Error oauth2Error = new OAuth2Error( - MISSING_SIGNATURE_VERIFIER_ERROR_CODE, - "Failed to find a Signature Verifier for Client Registration: '" + - clientRegistration.getRegistrationId() + "'. Check to ensure you have configured the JwkSet URI.", - null + MISSING_SIGNATURE_VERIFIER_ERROR_CODE, + "Failed to find a Signature Verifier for Client Registration: '" + + clientRegistration.getRegistrationId() + "'. Check to ensure you have configured the JwkSet URI.", + null ); throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); } @@ -208,88 +209,4 @@ public class OidcAuthorizationCodeAuthenticationProvider implements Authenticati } return jwtDecoder; } - - private void validateIdToken(OidcIdToken idToken, ClientRegistration clientRegistration) { - // 3.1.3.7 ID Token Validation - // http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation - - // Validate REQUIRED Claims - URL issuer = idToken.getIssuer(); - if (issuer == null) { - this.throwInvalidIdTokenException(); - } - String subject = idToken.getSubject(); - if (subject == null) { - this.throwInvalidIdTokenException(); - } - List audience = idToken.getAudience(); - if (CollectionUtils.isEmpty(audience)) { - this.throwInvalidIdTokenException(); - } - Instant expiresAt = idToken.getExpiresAt(); - if (expiresAt == null) { - this.throwInvalidIdTokenException(); - } - Instant issuedAt = idToken.getIssuedAt(); - if (issuedAt == null) { - this.throwInvalidIdTokenException(); - } - - // 2. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) - // MUST exactly match the value of the iss (issuer) Claim. - // TODO Depends on gh-4413 - - // 3. The Client MUST validate that the aud (audience) Claim contains its client_id value - // registered at the Issuer identified by the iss (issuer) Claim as an audience. - // The aud (audience) Claim MAY contain an array with more than one element. - // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, - // or if it contains additional audiences not trusted by the Client. - if (!audience.contains(clientRegistration.getClientId())) { - this.throwInvalidIdTokenException(); - } - - // 4. If the ID Token contains multiple audiences, - // the Client SHOULD verify that an azp Claim is present. - String authorizedParty = idToken.getAuthorizedParty(); - if (audience.size() > 1 && authorizedParty == null) { - this.throwInvalidIdTokenException(); - } - - // 5. If an azp (authorized party) Claim is present, - // the Client SHOULD verify that its client_id is the Claim Value. - if (authorizedParty != null && !authorizedParty.equals(clientRegistration.getClientId())) { - this.throwInvalidIdTokenException(); - } - - // 7. The alg value SHOULD be the default of RS256 or the algorithm sent by the Client - // in the id_token_signed_response_alg parameter during Registration. - // TODO Depends on gh-4413 - - // 9. The current time MUST be before the time represented by the exp Claim. - Instant now = Instant.now(); - if (!now.isBefore(expiresAt)) { - this.throwInvalidIdTokenException(); - } - - // 10. The iat Claim can be used to reject tokens that were issued too far away from the current time, - // limiting the amount of time that nonces need to be stored to prevent attacks. - // The acceptable range is Client specific. - Instant maxIssuedAt = Instant.now().plusSeconds(30); - if (issuedAt.isAfter(maxIssuedAt)) { - this.throwInvalidIdTokenException(); - } - - // 11. If a nonce value was sent in the Authentication Request, - // a nonce Claim MUST be present and its value checked to verify - // that it is the same value as the one that was sent in the Authentication Request. - // The Client SHOULD check the nonce value for replay attacks. - // The precise method for detecting replay attacks is Client specific. - // TODO Depends on gh-4442 - - } - - private void throwInvalidIdTokenException() { - OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE); - throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString()); - } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcTokenValidator.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcTokenValidator.java new file mode 100644 index 0000000000..b646015a66 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/authentication/OidcTokenValidator.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.client.oidc.authentication; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.util.CollectionUtils; + +import java.net.URL; +import java.time.Instant; +import java.util.List; + +/** + * @author Rob Winch + * @since 5.1 + */ +final class OidcTokenValidator { + private static final String INVALID_ID_TOKEN_ERROR_CODE = "invalid_id_token"; + + static void validateIdToken(OidcIdToken idToken, ClientRegistration clientRegistration) { + // 3.1.3.7 ID Token Validation + // http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + + // Validate REQUIRED Claims + URL issuer = idToken.getIssuer(); + if (issuer == null) { + throwInvalidIdTokenException(); + } + String subject = idToken.getSubject(); + if (subject == null) { + throwInvalidIdTokenException(); + } + List audience = idToken.getAudience(); + if (CollectionUtils.isEmpty(audience)) { + throwInvalidIdTokenException(); + } + Instant expiresAt = idToken.getExpiresAt(); + if (expiresAt == null) { + throwInvalidIdTokenException(); + } + Instant issuedAt = idToken.getIssuedAt(); + if (issuedAt == null) { + throwInvalidIdTokenException(); + } + + // 2. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) + // MUST exactly match the value of the iss (issuer) Claim. + // TODO Depends on gh-4413 + + // 3. The Client MUST validate that the aud (audience) Claim contains its client_id value + // registered at the Issuer identified by the iss (issuer) Claim as an audience. + // The aud (audience) Claim MAY contain an array with more than one element. + // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, + // or if it contains additional audiences not trusted by the Client. + if (!audience.contains(clientRegistration.getClientId())) { + throwInvalidIdTokenException(); + } + + // 4. If the ID Token contains multiple audiences, + // the Client SHOULD verify that an azp Claim is present. + String authorizedParty = idToken.getAuthorizedParty(); + if (audience.size() > 1 && authorizedParty == null) { + throwInvalidIdTokenException(); + } + + // 5. If an azp (authorized party) Claim is present, + // the Client SHOULD verify that its client_id is the Claim Value. + if (authorizedParty != null && !authorizedParty.equals(clientRegistration.getClientId())) { + throwInvalidIdTokenException(); + } + + // 7. The alg value SHOULD be the default of RS256 or the algorithm sent by the Client + // in the id_token_signed_response_alg parameter during Registration. + // TODO Depends on gh-4413 + + // 9. The current time MUST be before the time represented by the exp Claim. + Instant now = Instant.now(); + if (!now.isBefore(expiresAt)) { + throwInvalidIdTokenException(); + } + + // 10. The iat Claim can be used to reject tokens that were issued too far away from the current time, + // limiting the amount of time that nonces need to be stored to prevent attacks. + // The acceptable range is Client specific. + Instant maxIssuedAt = now.plusSeconds(30); + if (issuedAt.isAfter(maxIssuedAt)) { + throwInvalidIdTokenException(); + } + + // 11. If a nonce value was sent in the Authentication Request, + // a nonce Claim MUST be present and its value checked to verify + // that it is the same value as the one that was sent in the Authentication Request. + // The Client SHOULD check the nonce value for replay attacks. + // The precise method for detecting replay attacks is Client specific. + // TODO Depends on gh-4442 + + } + + private static void throwInvalidIdTokenException() { + OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE); + throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString()); + } + + private OidcTokenValidator() {} +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcTokenValidatorTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcTokenValidatorTests.java new file mode 100644 index 0000000000..08d4545fe7 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/authentication/OidcTokenValidatorTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.client.oidc.authentication; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * @author Rob Winch + * @since 5.1 + */ +public class OidcTokenValidatorTests { + private ClientRegistration.Builder registration = ClientRegistration.withRegistrationId("client-foo-bar") + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .authorizationUri("https://example.com/oauth2/authorize") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .userInfoUri("https://example.com/users/me") + .clientId("client-id") + .clientName("client-name") + .clientSecret("client-secret") + .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("user") + .tokenUri("https://example.com/oauth/access_token"); + + private Map claims = new HashMap<>(); + private Instant issuedAt = Instant.now(); + private Instant expiresAt = Instant.now().plusSeconds(3600); + + @Before + public void setup() { + this.claims.put(IdTokenClaimNames.ISS, "https://issuer.example.com"); + this.claims.put(IdTokenClaimNames.SUB, "rob"); + this.claims.put(IdTokenClaimNames.AUD, Arrays.asList("client-id")); + } + + @Test + public void validateIdTokenWhenValidThenNoException() { + assertThatCode(() -> validateIdToken()) + .doesNotThrowAnyException(); + } + + @Test + public void validateIdTokenWhenIssuerNullThenException() { + this.claims.remove(IdTokenClaimNames.ISS); + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenSubNullThenException() { + this.claims.remove(IdTokenClaimNames.SUB); + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenAudNullThenException() { + this.claims.remove(IdTokenClaimNames.AUD); + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenIssuedAtNullThenException() { + this.issuedAt = null; + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenExpiresAtNullThenException() { + this.expiresAt = null; + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenAudMultipleAndAzpNullThenException() { + this.claims.put(IdTokenClaimNames.AUD, Arrays.asList("client-id", "other")); + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenAzpNotClientIdThenException() { + this.claims.put(IdTokenClaimNames.AZP, "other"); + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenMulitpleAudAzpClientIdThenNoException() { + this.claims.put(IdTokenClaimNames.AUD, Arrays.asList("client-id", "other")); + this.claims.put(IdTokenClaimNames.AZP, "client-id"); + assertThatCode(() -> validateIdToken()) + .doesNotThrowAnyException(); + } + + @Test + public void validateIdTokenWhenExpiredThenException() { + this.issuedAt = Instant.now().minus(Duration.ofMinutes(1)); + this.expiresAt = this.issuedAt.plus(Duration.ofSeconds(1)); + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void validateIdTokenWhenIssuedAtWayInFutureThenException() { + this.issuedAt = Instant.now().plus(Duration.ofMinutes(5)); + this.expiresAt = this.issuedAt.plus(Duration.ofSeconds(1)); + assertThatCode(() -> validateIdToken()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + private void validateIdToken() { + OidcIdToken token = new OidcIdToken("token123", this.issuedAt, this.expiresAt, this.claims); + OidcTokenValidator.validateIdToken(token, this.registration.build()); + } + +}