2
0
Эх сурвалжийг харах

Extract OidcTokenValidator

Issue: gh-5330
Rob Winch 7 жил өмнө
parent
commit
3ddde473f2

+ 18 - 101
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<String> 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());
-	}
 }

+ 121 - 0
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<String> 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() {}
+}

+ 148 - 0
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<String, Object> 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());
+	}
+
+}